Merge commit '1dd261e918e35fbfbc4d0797611967a450a5d756' into block-notes

This commit is contained in:
Oliver Walters
2026-07-15 13:07:33 +00:00
412 changed files with 125043 additions and 112878 deletions
+3 -1
View File
@@ -46,4 +46,6 @@ jobs:
run: |
python ./.github/scripts/check_source_strings.py --frontend --backend
- name: Check Migration Files
run: python3 .github/scripts/check_migration_files.py
run: |
invoke migrate --detect
python3 .github/scripts/check_migration_files.py
+1 -1
View File
@@ -167,7 +167,7 @@ jobs:
with:
persist-credentials: false
- name: Set Up Python ${{ env.python_version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: ${{ env.python_version }}
- name: Version Check
+2 -2
View File
@@ -141,7 +141,7 @@ jobs:
- name: Install dependencies
run: invoke int.frontend-compile --extract
- name: Cache Playwright browsers
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: playwright-cache
with:
path: ~/.cache/ms-playwright
@@ -223,7 +223,7 @@ jobs:
- name: Install dependencies
run: invoke int.frontend-compile --extract
- name: Cache Playwright browsers
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: playwright-cache
with:
path: ~/.cache/ms-playwright
+8 -6
View File
@@ -112,7 +112,7 @@ jobs:
with:
persist-credentials: false
- name: Set up Python ${{ env.python_version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: ${{ env.python_version }}
cache: "pip"
@@ -156,7 +156,7 @@ jobs:
with:
persist-credentials: false
- name: Set up Python ${{ env.python_version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: ${{ env.python_version }}
- name: Check Config
@@ -301,7 +301,7 @@ jobs:
echo "after move"
ls -la artifact
rm -rf artifact
- uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0
- uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0
name: Commit schema changes
with:
commit_message: "Update API schema for ${{ env.version }} / ${{ github.sha }}"
@@ -363,7 +363,7 @@ jobs:
pip install .
if: needs.paths-filter.outputs.submit-performance == 'true'
- name: Performance Reporting
uses: CodSpeedHQ/action@63f3e98b61959fe67f146a3ff022e4136fe9bb9c # v4.17.6
uses: CodSpeedHQ/action@a4a36bb07c0638b0b4ca52bf1f3dad1b4289e52f # v4.18.1
# check if we are in inventree/inventree - reporting only works in that OIDC context
if: github.repository == 'inventree/InvenTree' && needs.paths-filter.outputs.submit-performance == 'true'
with:
@@ -405,7 +405,9 @@ jobs:
- name: Test Translations
run: invoke dev.translate
- name: Check Migration Files
run: python3 .github/scripts/check_migration_files.py
run: |
invoke migrate --detect
python3 .github/scripts/check_migration_files.py
- name: Coverage Tests
run: invoke dev.test --check --coverage --translations
- name: Upload raw coverage to artifacts
@@ -454,7 +456,7 @@ jobs:
env:
node_version: '>=24'
- name: Performance Reporting
uses: CodSpeedHQ/action@63f3e98b61959fe67f146a3ff022e4136fe9bb9c # v4.17.6
uses: CodSpeedHQ/action@a4a36bb07c0638b0b4ca52bf1f3dad1b4289e52f # v4.18.1
with:
mode: walltime
run: inv dev.test --pytest
+7
View File
@@ -10,10 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Breaking Changes
- [#11971](https://github.com/inventree/InvenTree/pull/11971) is a major refactor of how notes are handled. Notes are now stored in a separate database table (in line with how attachments are handled), and each model instance can have multiple notes associated with it. The `notes` field has been removed from the individual models (and their associated API endpoints), and notes are now accessed via the new `/api/note/` endpoint. Existing notes data (and any embedded images) are automatically migrated to the new notes table, with the markdown content converted to HTML. Any external client applications which read or write the `notes` field via the API will need to be updated to use the new endpoint.
- [#12360](https://github.com/inventree/InvenTree/pull/12360) removes the MPTT mixin from the StockItem model, and removes the self-referential tree structure from the database. This change was made to simplify the StockItem model and improve performance, as the MPTT tree structure was causing significant overhead in certain operations. Any external client applications which made use of the MPTT functionality will need to be updated to account for this change.
- [#12320](https://github.com/inventree/InvenTree/pull/12320) changes the default behavior of the `invoke migrate` command. Now, it no longer generates new migrations by default. Instead, it will only apply existing migrations to the database. If you want to detect and generate new migrations, you must now explicitly use the `--detect` flag. This change was made to prevent accidental generation of migrations when running the command, which could lead to unexpected changes in the database schema. Additionally, `invoke update` will no longer result in new migrations being generated, and will only apply existing migrations to the database. This change was made to ensure that the update process is predictable and does not introduce unexpected changes to the database schema.
- [#12223](https://github.com/inventree/InvenTree/pull/12223) removes support for python 3.11 and stops providing packages for Debian 11 and Ubuntu 20.04.
### Added
- [#12388](https://github.com/inventree/InvenTree/pull/12388) adds uniqueness requirements options for the Parameter and ParameterTemplate models. This allows users to specify whether a parameter value should be unique for a given model type, or globally unique across all models.
- [#12310](https://github.com/inventree/InvenTree/pull/12310) adds the ability to disassemble (or break apart) assembled stock items into their component parts, based on the Bill of Materials (BOM) associated with the stock item. This allows users to easily break down assembled items into their constituent parts, which can be useful for inventory management and tracking purposes.
- [#12117](https://github.com/inventree/InvenTree/pull/12117) adds a "preview" drawer to the InvenTree table component, allowing users to preview the details of a selected row without navigating away from the table view. This feature is optional and can be enabled or disabled via the `PREVIEW_DRAWER_ENABLED` system setting.
- [#12341](https://github.com/inventree/InvenTree/pull/12341) adds support for importing internal part prices.
- [#12295](https://github.com/inventree/InvenTree/pull/12295) adds "consumable" field to the Part model and API endpoints
- [#12250](https://github.com/inventree/InvenTree/pull/12250) adds "active" field to the ProjectCode model and API endpoints
+2 -2
View File
@@ -104,8 +104,8 @@ The project uses [Invoke](https://www.pyinvoketasks.com/) (`tasks.py`) as the ta
# One-time setup: creates venv at dev/venv/, installs deps, sets up pre-commit hooks
invoke dev.setup-dev
# Apply database migrations
invoke migrate
# Apply database migrations (and detect/create new migration files if required)
invoke migrate --detect
# Create an admin account (required to log in)
invoke superuser
+1 -1
View File
@@ -10,7 +10,7 @@
# - Monitors source files for any changes, and live-reloads server
# Base image last bumped 2026-06-16
FROM python:3.14.6-slim-trixie@sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061 AS inventree_base
FROM python:3.14.6-slim-trixie@sha256:b877e50bd90de10af8d82c57a022fc2e0dc731c5320d762a27986facfc3355c1 AS inventree_base
# Build arguments for this image
ARG commit_tag=""
+1 -1
View File
@@ -48,7 +48,7 @@ Users can authenticate against the API using basic authentication - specifically
Each user is assigned an authentication token which can be used to access the API. This token is persistent for that user (unless invalidated by an administrator) and can be used across multiple sessions.
!!! info "Token Administration"
User tokens can be created and/or invalidated via the user settings, [Admin Center](../settings/admin.md#admin-center) or admin interface.
User tokens can be created and/or invalidated via the user settings, [Admin Center](../settings/admin.md#admin-center), or the [Database Admin interface](../settings/db_admin.md).
#### Requesting a Token
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

+1 -1
View File
@@ -101,7 +101,7 @@ If enabled, InvenTree can retain logs of the most recent barcode scans. This can
Refer to the [barcode settings](../settings/global.md#barcodes) to enable barcode history logging.
The barcode history can be viewed via the admin panel in the web interface.
The barcode history can be viewed in the [Admin Center](../settings/admin.md#admin-center) in the web interface.
## Barcode Settings
+2 -2
View File
@@ -4,7 +4,7 @@ title: Importing Data
## Importing Data
External data can be imported via the admin interface, allowing for rapid integration of existing datasets, or bulk editing of table data.
External data can be imported via the [Admin Center](../settings/admin.md#admin-center), allowing for rapid integration of existing datasets, or bulk editing of table data.
!!! danger "Danger"
Uploading bulk data directly is a non-reversible action.
@@ -57,7 +57,7 @@ An import session can be initiated from a number of different contexts within th
Staff users can create an import session from within the [Admin Center](../settings/admin.md#admin-center). This is a general-purpose import session, and the user will be required to select the type of data to import.
Users can quickly navigate to the data import management page from the [spotlight search](../concepts/user_interface.md#spotlight), by searching for "import" and selecting the "Import data" option.
Users can quickly navigate to the data import management page from the [spotlight search](./ui/index.md#spotlight), by searching for "import" and selecting the "Import data" option.
### Data Tables
+41 -1
View File
@@ -30,12 +30,13 @@ Parameter templates are used to define the different types of parameters which a
| Choices | A comma-separated list of valid choices for parameter values linked to this template. |
| Checkbox | If set, parameters linked to this template can only be assigned values *true* or *false* |
| Selection List | If set, parameters linked to this template can only be assigned values from the linked [selection list](#selection-lists) |
| Unique | Enforce a [uniqueness requirement](#parameter-uniqueness) on parameter values linked to this template |
{{ image("concepts/parameter-template.png", "Parameters Template") }}
### Create Template
Parameter templates are created and edited via the [admin interface](../settings/admin.md).
Parameter templates are created and edited via the [Admin Center](../settings/admin.md#admin-center).
To create a template:
@@ -59,6 +60,45 @@ To add a parameter, navigate to a specific part detail page, click on the "Param
Select the parameter `Template` you would like to use for this parameter, fill-out the `Data` field (value of this specific parameter) and click the "Submit" button.
### Parameter Uniqueness
A parameter template can be configured to enforce a uniqueness requirement on the values of any parameters linked to it. This is useful for parameters which are expected to act as a unique identifier and prevents duplicate values from being entered by mistake.
The `Unique` attribute on a parameter template supports the following options:
| Option | Description |
| --- | --- |
| No uniqueness required | The default option - no restriction is placed on parameter values |
| Unique for model type | A parameter value must be unique amongst all other parameters (linked to this template) which are assigned to the *same* model type. For example, a template with this option enabled could be used to enforce unique serial numbers across all `Part` instances, without preventing the same value from also being used against a `Company` instance |
| Globally unique | A parameter value must be unique amongst *all* other parameters linked to this template, regardless of the model type to which they are assigned |
!!! info "Case Insensitive"
Uniqueness checks are case-insensitive - for example, the values `ABC123` and `abc123` are considered to be duplicates of each other.
If a parameter value is entered which does not satisfy the uniqueness requirement of its template, it will be rejected and an error message displayed.
#### Validation Against Numeric Value
If a parameter template defines a set of [units](#parameter-units), uniqueness is checked against the *normalized numeric value* of the parameter, rather than the raw text entered. This ensures that equivalent values expressed in different (but compatible) units are correctly detected as duplicates.
For example, if a *Resistance* template (with base units of `ohm`) enforces uniqueness, then a value of `1k` would be rejected as a duplicate of an existing value of `1000`, as they both represent the same physical quantity.
Templates which do not define a set of units are compared using a direct (case-insensitive) text comparison of the raw parameter value.
#### Copying Caveats
Some workflows in InvenTree allow parameters to be copied from one object to another - for example, when duplicating a part, build order, or other object which supports parameters.
Any parameter which is linked to a template with a uniqueness requirement is *skipped* when copying parameters in this way. This prevents the copy operation from creating a duplicate (and therefore invalid) value on the new object. If this occurs, the new object simply will not have a parameter created against that particular template - it can be added manually (with a distinct value) afterwards.
#### Category Parameter Caveats
A parameter template can be linked to a part category, along with a default value which is automatically applied to any new parts created within that category (or a sub-category).
This default value system is not compatible with a template which enforces a uniqueness requirement - applying the *same* default value to every part in a category would immediately conflict with the "unique" requirement, as soon as more than one part exists in that category.
For this reason, a category-based default value is *not* applied for any parameter template which has a uniqueness requirement configured. If you need to assign values for such parameters, this must be done manually (or via the API) on a per-part basis.
## Parametric Tables
Parametric tables gather all parameters from all objects of a particular type, to be sorted and filtered.
+37
View File
@@ -0,0 +1,37 @@
---
title: Forms
---
## Forms
Data entry and editing within InvenTree is typically performed through the use of forms, which provide a structured interface for inputting and modifying data. Forms are designed to be user-friendly and efficient, allowing users to quickly enter and update information within the system.
Forms are typically displayed as a modal dialog, separated into multiple sections and fields.
### Data Creation
Example: Creating a new part via the "Add Part" form:
{{ image("concepts/ui_form_add_part.png", "Add Part Button") }}
On several forms is displayed option "Keep form open" in bottom part of the form on left side of Submit button (option is visible on the screenshot above). When this switch is turned on, form window is not closed after submit and filled form data is not reset. This is useful for creating more entries at one time with similar properties (e.g. only different number in name).
### Data Editing
Example: Editing an existing purchase order via the "Edit Purchase Order" form:
{{ image("concepts/ui_form_edit_po.png", "Edit Purchase Order") }}
### Form Submission
A form can be submitted by clicking the "Submit" button located at the bottom-right corner of the form.
Alternatively, an open form can be submitted using the keyboard shortcut `Ctrl + Enter` (or `Cmd + Enter` on macOS). This shortcut works even while typing in a form field, providing a quick way to submit the form without needing to reach for the mouse. It is particularly useful in multi-line text fields, where pressing `Enter` inserts a new line rather than submitting the form.
The keyboard shortcut is only active while a form is open, and follows the same rules as the "Submit" button - for example, it has no effect while the form is loading, or when editing an existing item without any changes.
### Confirm Actions
Many actions within InvenTree require user confirmation before they can be executed. This is typically implemented through the use of confirmation dialogs, which prompt the user to confirm their intention before proceeding with the action.
{{ image("concepts/ui_form_hold_po.png", "Confirmation Dialog") }}
+31
View File
@@ -0,0 +1,31 @@
---
title: Global Search
---
## Global Search
Accessible from the [main menu](./index.md#main-menu), the global search functionality allows users to quickly find specific items or information within the InvenTree system. The search icon is located at the top of the interface and provides a convenient way to search across all sections of the system.
Clicking on the "search" icon (in the menu bar) opens the search menu, which allows users to enter search queries and view results from across the system.
{{ image("concepts/ui_global_search.png", "Global Search") }}
Search results are organized by category (e.g. Parts, Stock, Manufacturing, etc.) and provide quick access to the relevant pages for each search result.
### Detail View
To navigate to the detail page for a particular search result, simply click on the desired result from the search results list. This will take you directly to the relevant page within the InvenTree system, allowing you to view and interact with the specific item or information you were searching for.
### Full Results
The "global search" menu provides a limited set of search results for each category, typically showing the most relevant or recent results. To view the full set of search results for a particular category, click on the "View all results" button located at the top-left of the search results list for that category:
{{ image("concepts/ui_global_search_view_all.png", "View Full Search Results") }}
### Collapse Result Groups
To collapse a particular category of search results in the global search menu, click on the "collapse" icon located at the top-right corner of the search results list for that category. This will hide the search results for that category, allowing you to focus on other categories or search results.
### Remove Result Groups
To remove a particular category of search results from the global search menu, click on the "remove" icon located at the top-right corner of the search results list for that category.
+182
View File
@@ -0,0 +1,182 @@
---
title: User Interface
---
## User Interface
The InvenTree user interface is designed to be intuitive and user-friendly, providing easy access to the various features and functions of the system. The interface is organized into several key components, including navigation menus, settings, forms, tables, search functionality, and more.
The interface is designed for large-format displays, and as such is explicitly *not* optimized for mobile devices. However, the interface is responsive and should work on a wide range of desktop screen sizes.
## Navigation
Navigation throughout the InvenTree interface is designed to be straightforward and efficient, allowing users to quickly access the various sections and features of the system. The navigation is organized into several key areas, including the main menu, navigation menu, and page panels.
### Main Menu
The main menu is located at the top of the interface and provides access to the primary sections of the system:
{{ image("concepts/ui_main_menu.png", "Main Menu") }}
From the main menu, users can access the following items:
- [Navigation Menu](#navigation-menu)
- [Dashboard](#dashboard)
- [Global Search](./global_search.md)
- [Spotlight](#spotlight)
- [Barcode Scanning](#barcode-scanning)
- [Notifications](#notifications)
- [User Menu](#user-menu)
As well as allowing navigation to the following main sections:
- [Parts](../../part/index.md)
- [Stock](../../stock/index.md)
- [Manufacturing](../../manufacturing/index.md)
- [Purchasing](../../purchasing/index.md)
- [Sales](../../sales/index.md)
### Navigation Menu
The global navigation menu is located on the left-hand side of the interface and provides access to the various sections of the system.
{{ image("concepts/ui_navigation_menu.png", "Navigation Menu") }}
The navigation menu is organized into several key areas, including:
- **Navigation:** Provides access to the main sections of the system, including Parts, Stock, Manufacturing, Purchasing, and Sales.
- **Settings:** Quick access to user settings, system settings, and the admin center.
- **Actions:** Provides quick access to commonly used actions
- **Documentation:** Links to the online documentation.
- **About:** InvenTree version and license information.
### User Menu
The user menu is located in the top-right corner of the interface and provides access to user-specific settings and actions.
{{ image("concepts/ui_user_menu.png", "User Menu") }}
The user menu provides access to the following items:
- **User Settings:** Access to [user settings](../../settings/user.md).
- **System Settings:** Access to [global settings](../../settings/global.md) settings. *Note: Access to system settings may be restricted based on user permissions.*
- **Admin Center:** Access to the [admin center](../../settings/admin.md#admin-center) for operational administration
- **Database Admin Interface:** Low level administration via the [Database Admin Interface](../../settings/db_admin.md). *Note: Access may be restricted based on user permissions.*
- **Change Color Mode:** Toggle between light and dark color modes.
- **About InvenTree:** View version and license information about InvenTree.
- **Logout:** Log out of the InvenTree system.
### Page Panels
Most detail pages views within InvenTree are organized into panels, which provide a structured layout for displaying information and actions related to the current page.
Panels are arranged in a vertical stack on the left side of the page, with the main content area on the right. Each panel contains related information and actions, allowing users to easily navigate and interact with the content.
{{ image("concepts/ui_panels.png", "Page Panels") }}
#### Collapse Panels
The panel sidebar can be collapsed to provide more space for the main content area. To collapse or expand the panel sidebar, click the collapse icon located at the bottom of the sidebar. To expand the sidebar again, click the expand icon that appears when the sidebar is collapsed.
### Breadcrumbs
On some pages, a breadcrumb navigation trail is provided at the top of the page, just below the main menu. Breadcrumbs provide a visual representation of the user's current location within the system and allow for easy navigation back to previous pages.
{{ image("concepts/ui_breadcrumbs.png", "Breadcrumb Navigation") }}
### Navigation Tree
On some pages, a navigation tree is provided on the left-hand side of the page, next to the breadcrumbs. The navigation tree provides a hierarchical view of the current section of the system, allowing users to quickly navigate to related pages and sections.
Click on the navigation tree icon to expand the tree and view the available navigation options:
{{ image("concepts/ui_navigation_tree.png", "Navigation Tree") }}
#### Searching
The navigation tree includes a search bar at the top of the panel. Typing into the search bar filters the tree to show only entries that match the search query. When a search is active, all matching results are expanded and displayed in a flat list. Clearing the search field returns the tree to its normal browsing mode.
#### Highlight Selected Entry
The currently selected entry in the navigation tree is highlighted with a distinct background color, making it easy to identify the active page or section within the hierarchy.
#### Auto-Expand to Selected Entry
When the navigation tree is opened, it automatically expands to reveal the currently selected entry. All ancestor nodes in the hierarchy are expanded so the active entry is immediately visible, without requiring manual navigation through the tree.
## Dashboard
The dashboard provides a customizable landing page for users when they log in to the system. The dashboard can be configured to display a variety of widgets and information panels, providing users with quick access to important data and actions.
{{ image("concepts/ui_dashboard.png", "Dashboard") }}
### Editing Layout
To edit the layout (add, remove, or rearrange widgets) of the dashboard, open the dashboard context menu (located at the top-right corner of the dashboard) and view the available options:
{{ image("concepts/ui_dashboard_edit.png", "Dashboard Context Menu") }}
### Custom Widgets
In addition to the set of built-in widgets provided by InvenTree, custom dashboard widgets can be implemented using [plugins](../../plugins/mixins/ui.md#dashboard-items). This allows users to create personalized dashboard experiences tailored to their specific needs and workflows.
## Tables
Information throughout the InvenTree interface is often presented in tabular format. Table views support a wide range of features, including pagination, sorting, filtering, and data export.
Read more about working with table views on the dedicated [Tables](./tables.md) page.
## Preview Panels
Rather than navigating directly to an item's detail page, clicking on a table row can instead open a "preview" drawer, providing a quick summary of that item without leaving the current view.
Read more about this optional feature on the dedicated [Preview Panels](./preview_panels.md) page.
## Forms
Data entry and editing within InvenTree is typically performed through the use of forms, which provide a structured interface for inputting and modifying data.
Read more about working with forms on the dedicated [Forms](./forms.md) page.
## Global Search
Accessible from the [main menu](#main-menu), the global search functionality allows users to quickly find specific items or information within the InvenTree system.
Read more about the global search feature on the dedicated [Global Search](./global_search.md) page.
## Spotlight
The user interface features a "spotlight" search functionality, which provides a quick and efficient way to access common actions or navigate to specific pages within the InvenTree system. The spotlight search is designed to enhance user productivity by allowing users to quickly find and execute actions without needing to navigate through menus or remember specific page locations.
{{ image("concepts/ui_spotlight.png", "Spotlight Search") }}
### Open Spotlight
To open the "spotlight" search, click on the "spotlight" icon located in the main menu at the top of the interface. This will open the spotlight search menu, allowing you to enter search queries and view available actions.
Alternatively, the spotlight search can be opened using the keyboard shortcut `Ctrl + K` (or `Cmd + K` on macOS), providing a quick and convenient way to access the spotlight functionality without needing to click on the menu icon.
### Disable Spotlight
Users may opt to disable the spotlight search functionality if they do not find it useful or prefer not to use it. To disable the spotlight search, navigate to your [user settings](../../settings/user.md) and locate the option to disable the spotlight feature. Once disabled, the spotlight search will no longer be accessible from the main menu or via keyboard shortcuts.
## Copy Button
Many fields within the InvenTree user interface include a "copy" button, which allows users to quickly copy the value of that field to their clipboard. This is particularly useful for fields that contain important identifiers, such as part numbers, stock item codes, or other relevant data that may need to be easily copied and pasted elsewhere.
!!! important "Secure Context"
The "copy" button functionality relies on the browser's clipboard API, which may not be available in all contexts (e.g. if the user is accessing the InvenTree interface via a non-https connection, or through an embedded iframe or a non-standard browser). In such cases, the "copy" button may not function as intended.
## User Permissions
Many aspects of the user interface are controlled by user permissions, which determine what actions and features are available to each user based on their assigned roles and permissions within the system. This allows for a highly customizable user experience, where different users can have access to different features and functionality based on their specific needs and responsibilities within the organization.
If a user does not have permission to access a particular feature or section of the system, that feature will be hidden from their view in the user interface. This helps to ensure that users only see the features and information that are relevant to their role, reducing clutter and improving usability.
## Language Support
The InvenTree user interface supports multiple languages, allowing users to interact with the system in their preferred language.
The default system language can be configured by the system administrator in the [server configuration options](../../start/config.md#basic-options).
Additionally, users can select their preferred language in their [user settings](../../settings/user.md), allowing them to override the system default language with their own choice. This provides a personalized experience for each user, ensuring that they can interact with the system in the language they are most comfortable with.
+37
View File
@@ -0,0 +1,37 @@
---
title: Preview Panels
---
## Preview Panels
Many [table views](./tables.md) in InvenTree link each row to the detail page for the underlying item. By default, clicking on a row navigates directly to that detail page.
If the preview panel feature is enabled, clicking on a row instead opens a "preview" drawer on the right-hand side of the screen. The preview drawer displays a summary of the selected item, without navigating away from the current table view.
{{ image("concepts/ui_preview_drawer.png", "Preview Drawer") }}
## Enabling the Preview Panel
The preview panel is disabled by default. It can be enabled on a per-user basis via the **Table Preview Panel** option, found in the *Display Options* tab of the [user settings](../../settings/user.md) page.
## Using the Preview Panel
Once enabled, clicking on a row in a supported table opens the preview drawer for that item, rather than navigating to its detail page.
### Viewing Full Details
The preview drawer title includes an arrow icon, linking to the full detail page for the previewed item. Click on the title (or the arrow icon) to navigate to the detail page and close the drawer.
{{ image("concepts/ui_preview_details_link.png", "View Details Link") }}
### Following Links
Any link within the preview drawer (for example, a link to a related part or category) can be clicked to navigate directly to that page. The preview drawer closes automatically when a link is followed.
### Bypassing the Preview
To navigate directly to an item's detail page without opening the preview drawer, hold `Ctrl` (or `Cmd` on macOS) while clicking the row, or middle-click the row to open the detail page in a new tab.
### Closing the Preview
The preview drawer can be closed by clicking the close button, clicking outside the drawer, or pressing `Escape`.
+149
View File
@@ -0,0 +1,149 @@
---
title: Tables
---
## Table Views
Information throughout the InvenTree interface is often presented in tabular format, allowing users to easily view and interact with large datasets. Tables are designed to be flexible and customizable, providing a range of features to enhance the user experience.
{{ image("concepts/ui_table.png", "Table View") }}
### Pagination
The pagination controls are located at the bottom of the table, allowing users to navigate through large datasets by moving between pages. Users can also adjust the number of rows displayed per page using the pagination settings.
### Row Selection
For tables where data selection is supported, a checkbox is provided at the left-hand side of each row, allowing users to select one or more rows for further actions. A master checkbox is also provided in the table header, allowing users to quickly select or deselect all rows in the table.
!!! info "Pagination and Row Selection"
When using the "master select" checkbox to select all rows, only the rows on the current page will be selected.
{{ image("concepts/ui_table_row_selection.png", "Row Selection") }}
### Table Actions
A particular table view may have a set of actions associated with it, which are typically located at the top-left corner of the table. These actions may include options for adding new entries, or performing bulk actions on [selected rows](#row-selection).
{{ image("concepts/ui_table_actions.png", "Table Actions") }}
### Searching
Some tables support searching, allowing users to quickly find specific entries within the dataset. The search bar is located at the top-right corner of the table view:
{{ image("concepts/ui_table_search.png", "Table Search") }}
### Column Selection
Some tables allow the user to toggle the visibility of certain columns to, enabling a more customized view of the data.
Column selection is accessed via the "Select Columns" menu, located to the top-right of the table view:
{{ image("concepts/ui_table_column_selection.png", "Column Selection") }}
### Filtering
The dataset (which is fetched dynamically from the server via an API request) can be filtered by providing query parameters to the API endpoint.
Select the "table filters" button to open the filter selection menu
{{ image("concepts/ui_table_filter_button.png", "Table Filter Button") }}
{{ image("concepts/ui_table_filter_menu.png", "Table Filter Menu") }}
Table filters are saved across browser sessions, allowing users to maintain their preferred filter settings when returning to the particular table view.
#### Column Filters
Many table columns expose an inline filter icon directly in the column header, providing a quick way to filter by that column without opening the full filter drawer. Columns that support filtering display a small filter icon alongside the column title. The icon is highlighted when a filter for that column is currently active, giving an at-a-glance indication of which columns have active filters.
Clicking the icon opens a compact popover anchored to the column header:
{{ image("concepts/ui_table_column_filter_popover.png", "Column Filter Popover") }}
**Single-filter columns** — for columns linked to one filter (e.g. *Active*, *Has IPN*, *Status*), selecting a value immediately applies the filter and the popover closes automatically.
**Range columns** — for columns that represent a range concept (e.g. *Start Date*, *Target Date*, *Creation Date*), the popover stays open and presents multiple controls — for example *before* and *after* date pickers — so both bounds can be set in a single interaction.
Once a filter is active, the popover shows a badge with the current value and a remove button (red ×) instead of the value picker. Clicking the × clears only that column's filter.
!!! info "Column filters and the filter drawer share the same state"
Filters applied via a column popover appear immediately in the filter drawer's active-filter list, and filters added through the drawer are reflected in the column icons. Clearing all filters from the drawer also removes any filters set via column popovers.
#### Saved Filter Groups
Frequently used combinations of filters can be saved as a named *filter group*, allowing them to be quickly recalled later without having to re-add each filter individually.
The **Saved Filter Groups** panel is displayed at the bottom of the filter drawer. When one or more filters are active, a **Save current filters** button is available. Clicking it opens an inline name input — enter a name and press Enter (or click the confirm icon) to save the group. Press Escape or click the cancel icon to discard.
{{ image("concepts/ui_table_filter_group.png", "Filter Groups") }}
Previously saved filter groups are listed in the panel. Each entry shows the group name alongside two actions:
- **Load** (green reload icon): Replaces the current active filters with the filters stored in that group. The table immediately re-fetches data using the restored filters.
- **Delete** (red × icon): Permanently removes the saved filter group.
Saved filter groups are stored in the browser's local storage and are specific to each table or calendar view, so groups saved for one view are not available in another. They persist across local browser sessions until explicitly deleted. Filter groups are not shared to other devices.
!!! info "Loading a filter group replaces active filters"
Loading a saved filter group replaces all currently active filters with those stored in the group. Any unsaved active filters will be overwritten.
### Data Sorting
Some table columns support data sorting, allowing the dataset to be sorted in ascending or descending order based on the values in that column. To sort a column, click on the column header. Clicking the column header again will toggle the sort order between ascending and descending. The current sort order is indicated by an arrow icon in the column header.
{{ image("concepts/ui_table_sorting.png", "Data Sorting") }}
### Data Export
Some tables support downloading of the dataset in various formats (e.g. CSV, Excel, PDF). If data download is available for a given table, the "export data" button will be located at the top-right corner of the table view.
This opens the "Export Data" form, which allows the user to select the desired file format for download, as well as any additional options related to the data export.
{{ image("concepts/ui_table_download.png", "Data Download") }}
### Row Actions
In some tables, there may be specific actions associated with individual rows, allowing users to perform actions directly on a particular entry in the dataset. Row actions are typically accessed via an "actions" menu located at the right-hand side of each row.
{{ image("concepts/ui_table_row_actions.png", "Row Actions") }}
### Right-Click Context Menu
For rows that support row actions, a right-click context menu is also available, providing quick access to the same set of actions without needing to click on the "actions" menu.
{{ image("concepts/ui_table_context_menu.png", "Right-Click Context Menu") }}
### Row Navigation
For tables which reference other objects within the system, clicking on a row will navigate to the detail page for that particular entry. For example, clicking on a row in the "Part" table will navigate to the detail page for that specific part.
If the [preview panel](./preview_panels.md) feature is enabled, clicking on a row instead opens a preview drawer for that entry, rather than navigating directly to its detail page.
## Calendar Views
Some [table views](#table-views) associated with various order types can be switched to a calendar view, which provides a visual representation of data based on date fields. The calendar view allows users to easily see and interact with data that is organized by date, such as scheduled tasks, events, or deadlines.
To switch to the "calendar view" (for a table which supports it), click on the "calendar view" button located above and to the right of the table view:
{{ image("concepts/ui_calendar_select.png", "Calendar View Button") }}
This will display the data in a calendar format:
{{ image("concepts/ui_calendar_view.png", "Calendar View") }}
### Calendar Horizon
The calendar view provides a configurable "horizon" setting, which allows users to adjust the number of months displayed in the calendar view.
## Parametric Views
Some [table views](#table-views) can be switched to a parametric view, which provides a visual representation of data based on specific parameters or attributes. The parametric view allows users to easily see and interact with data that is organized by certain characteristics, such as categories, types, or other relevant attributes.
To switch to the "parametric view" (for a table which supports it), click on the "parametric view" button located above and to the right of the table view:
{{ image("concepts/ui_parametric_select.png", "Parametric View Button") }}
This will display the data in a parametric format:
{{ image("concepts/ui_parametric_view.png", "Parametric View") }}
-355
View File
@@ -1,355 +0,0 @@
---
title: User Interface
---
## User Interface
The InvenTree user interface is designed to be intuitive and user-friendly, providing easy access to the various features and functions of the system. The interface is organized into several key components, including navigation menus, settings, forms, tables, search functionality, and more.
The interface is designed for large-format displays, and as such is explicitly *not* optimized for mobile devices. However, the interface is responsive and should work on a wide range of desktop screen sizes.
## Navigation
Navigation throughout the InvenTree interface is designed to be straightforward and efficient, allowing users to quickly access the various sections and features of the system. The navigation is organized into several key areas, including the main menu, navigation menu, and page panels.
### Main Menu
The main menu is located at the top of the interface and provides access to the primary sections of the system:
{{ image("concepts/ui_main_menu.png", "Main Menu") }}
From the main menu, users can access the following items:
- [Navigation Menu](#navigation-menu)
- [Dashboard](#dashboard)
- [Global Search](#search)
- [Spotlight](#spotlight)
- [Barcode Scanning](#barcode-scanning)
- [Notifications](#notifications)
- [User Menu](#user-menu)
As well as allowing navigation to the following main sections:
- [Parts](../part/index.md)
- [Stock](../stock/index.md)
- [Manufacturing](../manufacturing/index.md)
- [Purchasing](../purchasing/index.md)
- [Sales](../sales/index.md)
### Navigation Menu
The global navigation menu is located on the left-hand side of the interface and provides access to the various sections of the system.
{{ image("concepts/ui_navigation_menu.png", "Navigation Menu") }}
The navigation menu is organized into several key areas, including:
- **Navigation:** Provides access to the main sections of the system, including Parts, Stock, Manufacturing, Purchasing, and Sales.
- **Settings:** Quick access to user settings, system settings, and the admin interface.
- **Actions:** Provides quick access to commonly used actions
- **Documentation:** Links to the online documentation.
- **About:** InvenTree version and license information.
### User Menu
The user menu is located in the top-right corner of the interface and provides access to user-specific settings and actions.
{{ image("concepts/ui_user_menu.png", "User Menu") }}
The user menu provides access to the following items:
- **User Settings:** Access to [user settings](../settings/user.md).
- **System Settings:** Access to [global settings](../settings/global.md) settings. *Note: Access to system settings may be restricted based on user permissions.*
- **Admin Interface:** Access to the [admin interface](../settings/admin.md) for data management. *Note: Access to the admin interface may be restricted based on user permissions.*
- **Change Color Mode:** Toggle between light and dark color modes.
- **About InvenTree:** View version and license information about InvenTree.
- **Logout:** Log out of the InvenTree system.
### Page Panels
Most detail pages views within InvenTree are organized into panels, which provide a structured layout for displaying information and actions related to the current page.
Panels are arranged in a vertical stack on the left side of the page, with the main content area on the right. Each panel contains related information and actions, allowing users to easily navigate and interact with the content.
{{ image("concepts/ui_panels.png", "Page Panels") }}
#### Collapse Panels
The panel sidebar can be collapsed to provide more space for the main content area. To collapse or expand the panel sidebar, click the collapse icon located at the bottom of the sidebar. To expand the sidebar again, click the expand icon that appears when the sidebar is collapsed.
### Breadcrumbs
On some pages, a breadcrumb navigation trail is provided at the top of the page, just below the main menu. Breadcrumbs provide a visual representation of the user's current location within the system and allow for easy navigation back to previous pages.
{{ image("concepts/ui_breadcrumbs.png", "Breadcrumb Navigation") }}
### Navigation Tree
On some pages, a navigation tree is provided on the left-hand side of the page, next to the breadcrumbs. The navigation tree provides a hierarchical view of the current section of the system, allowing users to quickly navigate to related pages and sections.
Click on the navigation tree icon to expand the tree and view the available navigation options:
{{ image("concepts/ui_navigation_tree.png", "Navigation Tree") }}
#### Searching
The navigation tree includes a search bar at the top of the panel. Typing into the search bar filters the tree to show only entries that match the search query. When a search is active, all matching results are expanded and displayed in a flat list. Clearing the search field returns the tree to its normal browsing mode.
#### Highlight Selected Entry
The currently selected entry in the navigation tree is highlighted with a distinct background color, making it easy to identify the active page or section within the hierarchy.
#### Auto-Expand to Selected Entry
When the navigation tree is opened, it automatically expands to reveal the currently selected entry. All ancestor nodes in the hierarchy are expanded so the active entry is immediately visible, without requiring manual navigation through the tree.
## Dashboard
The dashboard provides a customizable landing page for users when they log in to the system. The dashboard can be configured to display a variety of widgets and information panels, providing users with quick access to important data and actions.
{{ image("concepts/ui_dashboard.png", "Dashboard") }}
### Editing Layout
To edit the layout (add, remove, or rearrange widgets) of the dashboard, open the dashboard context menu (located at the top-right corner of the dashboard) and view the available options:
{{ image("concepts/ui_dashboard_edit.png", "Dashboard Context Menu") }}
### Custom Widgets
In addition to the set of built-in widgets provided by InvenTree, custom dashboard widgets can be implemented using [plugins](../plugins/mixins/ui.md#dashboard-items). This allows users to create personalized dashboard experiences tailored to their specific needs and workflows.
## Table Views
Information throughout the InvenTree interface is often presented in tabular format, allowing users to easily view and interact with large datasets. Tables are designed to be flexible and customizable, providing a range of features to enhance the user experience.
{{ image("concepts/ui_table.png", "Table View") }}
### Pagination
The pagination controls are located at the bottom of the table, allowing users to navigate through large datasets by moving between pages. Users can also adjust the number of rows displayed per page using the pagination settings.
### Row Selection
For tables where data selection is supported, a checkbox is provided at the left-hand side of each row, allowing users to select one or more rows for further actions. A master checkbox is also provided in the table header, allowing users to quickly select or deselect all rows in the table.
!!! info "Pagination and Row Selection"
When using the "master select" checkbox to select all rows, only the rows on the current page will be selected.
{{ image("concepts/ui_table_row_selection.png", "Row Selection") }}
### Table Actions
A particular table view may have a set of actions associated with it, which are typically located at the top-left corner of the table. These actions may include options for adding new entries, or performing bulk actions on [selected rows](#row-selection).
{{ image("concepts/ui_table_actions.png", "Table Actions") }}
### Searching
Some tables support searching, allowing users to quickly find specific entries within the dataset. The search bar is located at the top-right corner of the table view:
{{ image("concepts/ui_table_search.png", "Table Search") }}
### Column Selection
Some tables allow the user to toggle the visibility of certain columns to, enabling a more customized view of the data.
Column selection is accessed via the "Select Columns" menu, located to the top-right of the table view:
{{ image("concepts/ui_table_column_selection.png", "Column Selection") }}
### Filtering
The dataset (which is fetched dynamically from the server via an API request) can be filtered by providing query parameters to the API endpoint.
Select the "table filters" button to open the filter selection menu
{{ image("concepts/ui_table_filter_button.png", "Table Filter Button") }}
{{ image("concepts/ui_table_filter_menu.png", "Table Filter Menu") }}
Table filters are saved across browser sessions, allowing users to maintain their preferred filter settings when returning to the particular table view.
#### Column Filters
Many table columns expose an inline filter icon directly in the column header, providing a quick way to filter by that column without opening the full filter drawer. Columns that support filtering display a small filter icon alongside the column title. The icon is highlighted when a filter for that column is currently active, giving an at-a-glance indication of which columns have active filters.
Clicking the icon opens a compact popover anchored to the column header:
{{ image("concepts/ui_table_column_filter_popover.png", "Column Filter Popover") }}
**Single-filter columns** — for columns linked to one filter (e.g. *Active*, *Has IPN*, *Status*), selecting a value immediately applies the filter and the popover closes automatically.
**Range columns** — for columns that represent a range concept (e.g. *Start Date*, *Target Date*, *Creation Date*), the popover stays open and presents multiple controls — for example *before* and *after* date pickers — so both bounds can be set in a single interaction.
Once a filter is active, the popover shows a badge with the current value and a remove button (red ×) instead of the value picker. Clicking the × clears only that column's filter.
!!! info "Column filters and the filter drawer share the same state"
Filters applied via a column popover appear immediately in the filter drawer's active-filter list, and filters added through the drawer are reflected in the column icons. Clearing all filters from the drawer also removes any filters set via column popovers.
#### Saved Filter Groups
Frequently used combinations of filters can be saved as a named *filter group*, allowing them to be quickly recalled later without having to re-add each filter individually.
The **Saved Filter Groups** panel is displayed at the bottom of the filter drawer. When one or more filters are active, a **Save current filters** button is available. Clicking it opens an inline name input — enter a name and press Enter (or click the confirm icon) to save the group. Press Escape or click the cancel icon to discard.
{{ image("concepts/ui_table_filter_group.png", "Filter Groups") }}
Previously saved filter groups are listed in the panel. Each entry shows the group name alongside two actions:
- **Load** (green reload icon): Replaces the current active filters with the filters stored in that group. The table immediately re-fetches data using the restored filters.
- **Delete** (red × icon): Permanently removes the saved filter group.
Saved filter groups are stored in the browser's local storage and are specific to each table or calendar view, so groups saved for one view are not available in another. They persist across local browser sessions until explicitly deleted. Filter groups are not shared to other devices.
!!! info "Loading a filter group replaces active filters"
Loading a saved filter group replaces all currently active filters with those stored in the group. Any unsaved active filters will be overwritten.
### Data Sorting
Some table columns support data sorting, allowing the dataset to be sorted in ascending or descending order based on the values in that column. To sort a column, click on the column header. Clicking the column header again will toggle the sort order between ascending and descending. The current sort order is indicated by an arrow icon in the column header.
{{ image("concepts/ui_table_sorting.png", "Data Sorting") }}
### Data Export
Some tables support downloading of the dataset in various formats (e.g. CSV, Excel, PDF). If data download is available for a given table, the "export data" button will be located at the top-right corner of the table view.
This opens the "Export Data" form, which allows the user to select the desired file format for download, as well as any additional options related to the data export.
{{ image("concepts/ui_table_download.png", "Data Download") }}
### Row Actions
In some tables, there may be specific actions associated with individual rows, allowing users to perform actions directly on a particular entry in the dataset. Row actions are typically accessed via an "actions" menu located at the right-hand side of each row.
{{ image("concepts/ui_table_row_actions.png", "Row Actions") }}
### Right-Click Context Menu
For rows that support row actions, a right-click context menu is also available, providing quick access to the same set of actions without needing to click on the "actions" menu.
{{ image("concepts/ui_table_context_menu.png", "Right-Click Context Menu") }}
### Row Navigation
For tables which reference other objects within the system, clicking on a row will navigate to the detail page for that particular entry. For example, clicking on a row in the "Part" table will navigate to the detail page for that specific part.
## Calendar Views
Some [table views](#table-views) associated with various order types can be switched to a calendar view, which provides a visual representation of data based on date fields. The calendar view allows users to easily see and interact with data that is organized by date, such as scheduled tasks, events, or deadlines.
To switch to the "calendar view" (for a table which supports it), click on the "calendar view" button located above and to the right of the table view:
{{ image("concepts/ui_calendar_select.png", "Calendar View Button") }}
This will display the data in a calendar format:
{{ image("concepts/ui_calendar_view.png", "Calendar View") }}
### Calendar Horizon
The calendar view provides a configurable "horizon" setting, which allows users to adjust the number of months displayed in the calendar view.
## Parametric Views
Some [table views](#table-views) can be switched to a parametric view, which provides a visual representation of data based on specific parameters or attributes. The parametric view allows users to easily see and interact with data that is organized by certain characteristics, such as categories, types, or other relevant attributes.
To switch to the "parametric view" (for a table which supports it), click on the "parametric view" button located above and to the right of the table view:
{{ image("concepts/ui_parametric_select.png", "Parametric View Button") }}
This will display the data in a parametric format:
{{ image("concepts/ui_parametric_view.png", "Parametric View") }}
## Forms
Data entry and editing within InvenTree is typically performed through the use of forms, which provide a structured interface for inputting and modifying data. Forms are designed to be user-friendly and efficient, allowing users to quickly enter and update information within the system.
Forms are typically displayed as a modal dialog, separated into multiple sections and fields.
### Data Creation
Example: Creating a new part via the "Add Part" form:
{{ image("concepts/ui_form_add_part.png", "Add Part Button") }}
On several forms is displayed option "Keep form open" in bottom part of the form on left side of Submit button (option is visible on the screenshot above). When this switch is turned on, form window is not closed after submit and filled form data is not reset. This is useful for creating more entries at one time with similar properties (e.g. only different number in name).
### Data Editing
Example: Editing an existing purchase order via the "Edit Purchase Order" form:
{{ image("concepts/ui_form_edit_po.png", "Edit Purchase Order") }}
### Confirm Actions
Many actions within InvenTree require user confirmation before they can be executed. This is typically implemented through the use of confirmation dialogs, which prompt the user to confirm their intention before proceeding with the action.
{{ image("concepts/ui_form_hold_po.png", "Confirmation Dialog") }}
## Global Search
Accessible from the [main menu](#main-menu), the global search functionality allows users to quickly find specific items or information within the InvenTree system. The search icon is located at the top of the interface and provides a convenient way to search across all sections of the system.
Clicking on the "search" icon (in the menu bar) opens the search menu, which allows users to enter search queries and view results from across the system.
{{ image("concepts/ui_global_search.png", "Global Search") }}
Search results are organized by category (e.g. Parts, Stock, Manufacturing, etc.) and provide quick access to the relevant pages for each search result.
### Detail View
To navigate to the detail page for a particular search result, simply click on the desired result from the search results list. This will take you directly to the relevant page within the InvenTree system, allowing you to view and interact with the specific item or information you were searching for.
### Full Results
The "global search" menu provides a limited set of search results for each category, typically showing the most relevant or recent results. To view the full set of search results for a particular category, click on the "View all results" button located at the top-left of the search results list for that category:
{{ image("concepts/ui_global_search_view_all.png", "View Full Search Results") }}
### Collapse Result Groups
To collapse a particular category of search results in the global search menu, click on the "collapse" icon located at the top-right corner of the search results list for that category. This will hide the search results for that category, allowing you to focus on other categories or search results.
### Remove Result Groups
To remove a particular category of search results from the global search menu, click on the "remove" icon located at the top-right corner of the search results list for that category.
## Spotlight
The user interface features a "spotlight" search functionality, which provides a quick and efficient way to access common actions or navigate to specific pages within the InvenTree system. The spotlight search is designed to enhance user productivity by allowing users to quickly find and execute actions without needing to navigate through menus or remember specific page locations.
{{ image("concepts/ui_spotlight.png", "Spotlight Search") }}
### Open Spotlight
To open the "spotlight" search, click on the "spotlight" icon located in the main menu at the top of the interface. This will open the spotlight search menu, allowing you to enter search queries and view available actions.
Alternatively, the spotlight search can be opened using the keyboard shortcut `Ctrl + K` (or `Cmd + K` on macOS), providing a quick and convenient way to access the spotlight functionality without needing to click on the menu icon.
### Disable Spotlight
Users may opt to disable the spotlight search functionality if they do not find it useful or prefer not to use it. To disable the spotlight search, navigate to your [user settings](../settings/user.md) and locate the option to disable the spotlight feature. Once disabled, the spotlight search will no longer be accessible from the main menu or via keyboard shortcuts.
## Copy Button
Many fields within the InvenTree user interface include a "copy" button, which allows users to quickly copy the value of that field to their clipboard. This is particularly useful for fields that contain important identifiers, such as part numbers, stock item codes, or other relevant data that may need to be easily copied and pasted elsewhere.
!!! important "Secure Context"
The "copy" button functionality relies on the browser's clipboard API, which may not be available in all contexts (e.g. if the user is accessing the InvenTree interface via a non-https connection, or through an embedded iframe or a non-standard browser). In such cases, the "copy" button may not function as intended.
## User Permissions
Many aspects of the user interface are controlled by user permissions, which determine what actions and features are available to each user based on their assigned roles and permissions within the system. This allows for a highly customizable user experience, where different users can have access to different features and functionality based on their specific needs and responsibilities within the organization.
If a user does not have permission to access a particular feature or section of the system, that feature will be hidden from their view in the user interface. This helps to ensure that users only see the features and information that are relevant to their role, reducing clutter and improving usability.
## Language Support
The InvenTree user interface supports multiple languages, allowing users to interact with the system in their preferred language.
The default system language can be configured by the system administrator in the [server configuration options](../start/config.md#basic-options).
Additionally, users can select their preferred language in their [user settings](../settings/user.md), allowing them to override the system default language with their own choice. This provides a personalized experience for each user, ensuring that they can interact with the system in the language they are most comfortable with.
+1 -1
View File
@@ -184,7 +184,7 @@ django-upgrade --target-version {{ config.extra.django_version }} `find . -name
## Migration Files
Any required migration files **must** be included in the commit, or the pull-request will be rejected. If you change the underlying database schema, make sure you run `invoke migrate` and commit the migration files before submitting the PR.
Any required migration files **must** be included in the commit, or the pull-request will be rejected. If you change the underlying database schema, make sure you run `invoke migrate --detect` and commit the migration files before submitting the PR.
*Note: A github action checks for unstaged migration files and will reject the PR if it finds any!*
+6
View File
@@ -23,3 +23,9 @@ Read more about build orders in the [Build Order documentation](./build.md).
InvenTree allows users to allocate stock items to specific build orders, ensuring that the required components are reserved for production. This helps to prevent stock shortages and ensures that the right parts are available when needed.
Read more about stock allocation in the [Stock Allocation documentation](./allocate.md).
### Disassembly
The reverse process is also supported - an assembled stock item can be broken back down into its component parts, based on its BOM. This is useful for reworking or scrapping an assembly, or for splitting a bundled "kit" product purchased from a supplier into its individual components.
Read more about this process in the [Stock Disassembly documentation](../stock/disassemble.md).
+1 -1
View File
@@ -85,4 +85,4 @@ In addition to the primary methods for creating or importing part data, the foll
- [Via the REST API](../api/index.md)
- [Using the Python library](../api/python/index.md)
- [Within the Admin interface](../settings/admin.md)
- [Within the Database Admin interface](../settings/db_admin.md)
+2
View File
@@ -53,6 +53,8 @@ A *Template* part is one which can have *variants* which exist underneath it. [R
If a part is designated as an *Assembly* it can be created (or built) from other component parts. As an example, a circuit board assembly is made using multiple electronic components, which are tracked in the system. An *Assembly* Part has a Bill of Materials (BOM) which lists all the required sub-components. [Read further information about BOM management here](../manufacturing/bom.md).
An assembled stock item can also be broken back down into its component parts, using the [disassembly](../stock/disassemble.md) process.
### Component
If a part is designated as a *Component* it can be used as a sub-component of an *Assembly*. [Read further information about BOM management here](../manufacturing/bom.md)
+1 -1
View File
@@ -7,7 +7,7 @@ title: Part Notifications
Users can select to receive notifications when certain events occur.
!!! warning "Email Configuration Required"
External notifications require correct [email configuration](../start/config.md#email-settings). They also need to be enabled in the settings under notifications`.
External notifications require correct [email configuration](../start/config.md#email-settings). They also need to be enabled in the settings under *Notifications*.
!!! warning "Valid Email Address"
Each user must have a valid email address associated with their account to receive email notifications
+1 -1
View File
@@ -51,7 +51,7 @@ Each price range is calculated in the [Default Currency](../concepts/pricing.md#
Price range data is [cached in the database](#price-data-caching) when underlying pricing information changes.
!!! tip "Refresh Pricing"
While pricing data is [automatically updated](#data-updates), the user can also manually refresh the pricing calculations manually, by pressing the "Refresh" button in the overview section.
While pricing data is [automatically updated](#pricing-updates), the user can also manually refresh the pricing calculations manually, by pressing the "Refresh" button in the overview section.
#### Overall Pricing
+2 -2
View File
@@ -41,7 +41,7 @@ If this tab is not visible, ensure that the *Enable Stock History* [user setting
### Stocktake Entry Generation
By default, stocktake entries are generated automatically at regular intervals (see [settings](#stock-history-settings) below). However, users can generate a stocktake entry on demand, using the *Generate Stocktake Entry* button in the *Stock History* tab:
By default, stocktake entries are generated automatically at regular intervals (see [settings](#stocktake-settings) below). However, users can generate a stocktake entry on demand, using the *Generate Stocktake Entry* button in the *Stock History* tab:
{{ image("part/part_stocktake_manual.png", "Generate stocktake entry") }}
@@ -95,7 +95,7 @@ Enable or disable stocktake functionality. Note that by default, stocktake funct
### Automatic Stocktake Period
Configure the number of days between generation of [automatic stocktake reports](#automatic-stocktake). If this value is set to zero, automatic stocktake reports will not be generated.
Configure the number of days between generation of [automatic stocktake entries](#stocktake-entry-generation). If this value is set to zero, automatic stocktake entries will not be generated.
### Delete Old Stocktake Entries
+17 -1
View File
@@ -8,7 +8,7 @@ The Part detail view page provides a detailed view of a single part in the syste
### Category Breadcrumb List
The categories of each part is displayed on the top navigation bar as show in the above screenshot.
The categories of each part is displayed on the top navigation bar.
[Click here](./index.md#part-category) for more information about categories.
## Part Details
@@ -83,6 +83,10 @@ The *Build Orders* tab shows a list of the builds for this part. It provides a v
The *Used In* tab displays a list of other parts that this part is used to make. This tab is only visible if the Part is a *component*.
### Part Pricing
The *Part Pricing* tab displays all available pricing information for the part, aggregated from multiple sources (internal pricing, supplier pricing, purchase history, BOM pricing, sale pricing, etc). Refer to the [part pricing documentation](./pricing.md) for further information.
### Suppliers
The *Suppliers* tab displays all the *Part Suppliers* and *Part Manufacturers* for the selected *Part*.
@@ -101,6 +105,14 @@ This tab is only displayed if the part is marked as *Purchaseable*.
The *Sales Orders* tab shows a list of the sales orders for this part. It provides a view for important sales order information like customer, status, creation and shipment dates.
### Return Orders
The *Return Orders* tab shows a list of the [return orders](../sales/return_order.md) which reference this part. This tab is only visible if the Part is marked as *Salable*, and the return order feature is enabled.
### Transfer Orders
The *Transfer Orders* tab shows a list of the [transfer orders](../stock/transfer_order.md) which reference this part. This tab is hidden if the Part is marked as *Virtual*, or the transfer order feature is not enabled.
### Stock History
The *Stock History* tab provide historical stock level information. Refer to the [stock history documentation](./stocktake.md) for further information.
@@ -109,6 +121,10 @@ The *Stock History* tab provide historical stock level information. Refer to the
If a part is marked as *testable*, the user can define tests which must be performed on any stock items which are instances of this part. [Read more about testing](./test.md).
### Test Results
The *Test Results* tab displays [test result](../stock/test.md) data uploaded against *any* stock item of this part, aggregated into a single table. This differs from the *Test Templates* tab, which configures the tests themselves rather than displaying recorded results. This tab is only visible if the part is marked as *testable*.
### Related Parts
Related Part denotes a relationship between two parts, when users want to show their usage is "related" to another part or simply emphasize a link between two parts.
+2 -2
View File
@@ -113,10 +113,10 @@ Refer to the [sample plugins]({{ sourcedir("src/backend/InvenTree/plugin/samples
A *PluginConfig* database entry will be created for each plugin "discovered" when the server launches. This configuration entry is used to determine if a particular plugin is enabled.
The configuration entries must be enabled via the [InvenTree admin interface](../settings/admin.md).
The configuration entries must be enabled via the [Admin Center](../settings/admin.md#admin-center).
!!! 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.
Newly discovered plugins are disabled by default, and must be manually enabled (in the Admin Center) by a user with staff privileges.
## Plugin Mixins
+1 -1
View File
@@ -74,7 +74,7 @@ npm install
npm run build
```
Copy the built `attachment_carousel` directory to the `inventree-data/plugins` directory and enable it via the admin interface.
Copy the built `attachment_carousel` directory to the `inventree-data/plugins` directory and enable it via the [Admin Center](../settings/admin.md#admin-center).
![Attachment Carousel in Inventree panel screenshot](../assets/images/plugin/plugin_walkthrough_default.png "Attachment Carousel in Inventree panel screenshot")
+16
View File
@@ -135,6 +135,22 @@ The unit cost of the purchase order line item is transferred across to the creat
However, if the [Convert Currency](#purchase-order-settings) setting is enabled, the currency of the stock item will be converted to the [default currency](../concepts/pricing.md#default-currency) of the system. This may be useful when ordering stock in a different currency, to ensure that the unit cost of the stock item is converted to the base currency at the time of receipt.
## Bundled Items
Some suppliers only sell a group of components as a single bundled or "kit" product, rather than as individual purchasable line items - for example, a fastener kit containing an assortment of different screws, or a "starter kit" containing several components required for a particular use case.
Rather than receiving the bundle as a single opaque stock quantity, InvenTree allows the bundle to be modelled as an assembly, so that it can be broken apart into its individual components once required:
1. Create a part to represent the bundle itself, and mark it as an [assembly](../part/index.md#assembly)
2. Link a [supplier part](./supplier.md#supplier-parts) to the bundle part, representing how it is purchased from the supplier
3. Define a [Bill of Materials](../manufacturing/bom.md) for the bundle part, listing each of the individual components and the quantity contained within a single bundle
4. Create and receive a purchase order against the bundle's supplier part, as normal - a single stock item is created for the bundle, retaining the purchase price and source purchase order of the order as a whole
Once the individual components are actually required, the received bundle stock item can be [disassembled](../stock/disassemble.md) into its component parts. The purchase price and traceability data (batch code, source purchase order) of the original bundle are automatically apportioned across the newly generated component stock items.
!!! tip "Pack Size vs Bundled Items"
A supplier part with a [pack size](./supplier.md#supplier-part-pack-size) greater than one still represents multiple units of the *same* part - the pack size simply determines how many physical units are added to stock per unit ordered. A *bundled* item is different: a single supplier part represents an assortment of *different* components, which must be disassembled before the individual components can be used or sold separately.
## Complete Order
Once the quantity of all __received__ items is equal or above the quantity of all line items, the order will be automatically marked as __complete__.
+3
View File
@@ -89,3 +89,6 @@ Supplier parts can have a pack size defined. This value is defined when creating
When buying parts, they are bought in packs. This is taken into account in Purchase Orders: if a supplier part with a pack size of 5 is bought in a quantity of 4, 20 parts will be added to stock when the parts are received.
When adding stock manually, the supplier part can be added in packs or in individual parts. This is to allow the addition of items in opened packages. Set the flag "Use pack size" (`use_pack_size` in the API) to True in order to add parts in packs.
!!! tip "Bundled Items"
A pack size only ever represents multiple units of the *same* part. If a supplier instead sells a kit or assortment of *different* components as a single purchasable item, refer to the [bundled items](./purchase_order.md#bundled-items) documentation instead.
+3 -3
View File
@@ -286,7 +286,7 @@ Each part object has access to a lot of context variables about the part. The fo
| icon | The name of the icon if set, e.g. fas fa-warehouse |
| item_count | Simply returns the number of stock items in this location |
| name | The name of the location. This is only the name of this location, not the path |
| owner | The owner of the location if it has one. The owner can only be assigned in the admin interface |
| owner | The owner of the location if it has one |
| parent | The parent location. Returns None if it is already the top most one |
| path | A queryset of locations that contains the hierarchy starting from the top most parent |
| pathstring | A string that contains all names of the path separated by slashes e.g. A/B/C |
@@ -307,8 +307,8 @@ Each part object has access to a lot of context variables about the part. The fo
| contact | Contact Name |
| phone | Contact phone number |
| email | Contact email address |
| link | A second URL to the company (Actually only accessible in the admin interface) |
| notes | Extra notes about the company (Actually only accessible in the admin interface) |
| link | URL associated with the company |
| notes | Extra notes about the company |
| is_customer | Boolean value, is this company a customer |
| is_supplier | Boolean value, is this company a supplier |
| is_manufacturer | Boolean value, is this company a manufacturer |
+2 -2
View File
@@ -65,8 +65,8 @@ Label and report templates are created and edited using the built-in [template e
!!! tip "Staff Access Only"
Only users with staff access can create, upload or edit templates, snippets and assets.
!!! info "Backend Admin Interface"
Templates can also be managed at a lower level via the [backend admin interface](../settings/admin.md#backend-admin-interface), under the *Report* section. This is recommended for advanced users only.
!!! info "Database Admin Interface"
Templates can also be managed at a lower level via the [Database Admin interface](../settings/db_admin.md), under the *Report* section. This is recommended for advanced users only.
### Name and Description
+1 -1
View File
@@ -16,4 +16,4 @@ To make MFA mandatory for all users:
### Security Consideration
A user can lock themselves out if they lose access to both the device with their TOTP app and their backup tokens. An admin can delete their tokens from the admin pages (they exist under the 'TOTP devices' / 'static devices' models) . This should be a last resort and only done by people knowledgeable about the [admin pages](../settings/admin.md) as changes there might circumvent InvenTree's business and security logic.
A user can lock themselves out if they lose access to both the device with their TOTP app and their backup tokens. An admin can delete their tokens from the Database Admin interface (they exist under the 'TOTP devices' / 'static devices' models). This should be a last resort and only done by people knowledgeable about the [Database Admin interface](../settings/db_admin.md), as changes there might circumvent InvenTree's business and security logic.
+2 -2
View File
@@ -18,7 +18,7 @@ The basic requirements for configuring SSO are outlined below:
1. Enable backend for each required SSO provider(s) in the [config file or environment variables](../start/config.md#single-sign-on).
1. Create an external *app* with your provider of choice
1. Add the required client configurations in the `SocialApp` app in the [admin interface](../settings/admin.md).
1. Add the required client configurations in the `SocialApp` app in the [Database Admin interface](../settings/db_admin.md).
1. Configure the *callback* URL for the external app.
1. Enable SSO for the users in the [global settings](../settings/global.md).
1. Configure [e-mail](../settings/email.md).
@@ -161,4 +161,4 @@ Make sure all users with admin privileges have sufficient passwords - they can r
## Error Handling
If you encounter an error during the SSO process, the error should be logged in the InvenTree database. You can view the [error log](./logs.md) in the [admin interface](./admin.md) to see the details of the error.
If you encounter an error during the SSO process, the error should be logged in the InvenTree database. You can view the [error log](./logs.md) in the [Admin Center](./admin.md#admin-center) to see the details of the error.
+18 -47
View File
@@ -4,24 +4,23 @@ title: InvenTree Admin Interfaces
## InvenTree Admin Interfaces
There are multiple administration interfaces available in InvenTree, which provide different levels of access to the underlying resources and different operational safety.
InvenTree provides multiple administration interfaces with different safety levels and intended use cases.
[**Admin Center**](#admin-center):
- Main interface for managing InvenTree
- Robust verification and safety checks
- Main administration interface for day-to-day operations
- Uses API-backed flows with validation and safety checks
[**System Settings**](#system-settings):
- Access to all settings
- Robust verification, requires reading the documentation
- Access to global runtime settings
- Available to staff users (or users with equivalent API scope)
[**Backend Admin Interface**](#backend-admin-interface):
[**Database Admin Interface**](./db_admin.md):
- Low level access to the database
- Few verification or safety checks
- Requires knowledge of InvenTree internals
- Recommended for advanced users only
- Low-level database administration
- Fewer safeguards than the Admin Center
- Intended for advanced users and troubleshooting scenarios
### Admin Center
@@ -34,7 +33,13 @@ The Admin Center is the main interface for managing InvenTree. It provides a use
- Integration with external services (via machines and plugins)
- Reporting and statistics
It can be access via the *Admin Center* link in the top right user menu, the *Admin Center* quick-link in the command palette, or via the navigation menu.
#### Access Admin Center
The Admin Center can be accessed in any of the following ways:
- User menu in the top-right corner: *Admin Center*
- Command palette quick action: *Admin Center*
- Direct URL: `/web/settings/admin`
#### Permissions
@@ -44,40 +49,6 @@ Some panes can only be accessed by users with specific permissions. For example,
The System Settings interface provides ordered access to all global settings in InvenTree. Users need to have _staff_ privileges enabled or the _a:staff_ scope.
### Backend Admin Interface
### Database Admin Interface
Users which have *staff* privileges have access to an Admin interface which provides extremely low level control of the database. Every item in the database is available and this interface provides a unrestricted option for directly viewing and modifying database objects.
!!! warning "Caution"
Admin users should exercise extreme care when modifying data via the admin interface, as performing the wrong action may have unintended consequences!
The admin interface allows *staff* users the ability to directly view / add / edit / delete database entries according to their [user permissions](./permissions.md).
#### Access Backend Admin Interface
To directly access the admin interface, append /admin/ to the InvenTree site URL - e.g. http://localhost:8000/admin/.
An administration panel will be presented as shown below:
{{ image("admin/admin.png", "Admin panel") }}
#### View Database Objects
Database objects can be listed and filtered directly. The image below shows an example of displaying existing part categories.
{{ image("admin/part_cats.png", "Part categories") }}
!!! info "Permissions"
A "staff" account does not necessarily provide access to all administration options, depending on the roles assigned to the user.
##### Filtering
Some admin views support filtering of results against specified criteria. For example, the list of Part objects can be filtered as follows:
{{ image("admin/filter.png", "Filter part list") }}
#### Edit Database Objects
Individual database objects can be edited directly in the admin interface. The image below shows an example of editing a Part object:
{{ image("admin/edit_part.png", "Edit part") }}
For low-level administration tasks, use the [Database Admin Interface](./db_admin.md).
+54
View File
@@ -0,0 +1,54 @@
---
title: InvenTree Database Admin Interface
---
## Database Admin Interface
The Database Admin interface provides low-level access to InvenTree database objects.
!!! danger "Low-Level Interface"
The Database Admin bypasses many of the application-level safety checks used in the Admin Center.
Incorrect edits can create inconsistent data, break workflows, or expose security issues.
Use this interface only if you understand the data model and operational impact.
!!! warning "Recommended Usage"
Prefer the [Admin Center](./admin.md#admin-center) for routine administration.
Use the Database Admin only for advanced administration and troubleshooting.
### Access Database Admin Interface
Access to the Database Admin requires a user account with *staff* privileges.
Use one of the following methods:
- Append `/admin/` to the base InvenTree URL (for example: `http://localhost:8000/admin/`)
- Use the configured administrator URL from `INVENTREE_ADMIN_URL`
{{ image("admin/admin.png", "Database Admin panel") }}
### Permissions
A "staff" account does not necessarily provide access to all administration options, depending on the roles assigned to the user.
### View Database Objects
Database objects can be listed and filtered directly. The image below shows an example of displaying existing part categories.
{{ image("admin/part_cats.png", "Part categories") }}
#### Filtering
Some admin views support filtering of results against specified criteria. For example, the list of Part objects can be filtered as follows:
{{ image("admin/filter.png", "Filter part list") }}
### Edit Database Objects
Individual database objects can be edited directly in the Database Admin interface. The image below shows an example of editing a Part object:
{{ image("admin/edit_part.png", "Edit part") }}
!!! danger "Before You Save Changes"
Verify your changes carefully before saving.
If possible, test changes in a non-production environment first.
Record what you changed so it can be reviewed and reverted if needed.
+6 -9
View File
@@ -1,22 +1,19 @@
---
title: Admin Shell
title: Error Logs
---
## Error Logs
Any critical server error logs are recorded to the database, and can be viewed by staff users using the admin interface.
Any critical server error logs are recorded to the database, and can be viewed by staff users in the [Admin Center](./admin.md#admin-center), under the *Error Reports* section:
In the admin interface, select the "Errors" view:
{{ image("admin/admin_errors_link.png", "Error Reports in the Admin Center") }}
{{ image("admin/admin_errors_link.png", "Admin errors") }}
!!! info "URL"
Alternatively, navigate to the error list view at /admin/error_report/error/
A list of error logs is presented.
A list of error logs is presented. Select an entry to view the full error details, including the traceback.
{{ image("admin/admin_errors.png", "Error logs") }}
!!! info "Database Admin Interface"
Error logs can also be viewed via the [Database Admin interface](./db_admin.md), at the URL `/admin/error_report/error/`
!!! info "Deleting Logs"
Error logs should be deleted periodically
+5 -5
View File
@@ -7,7 +7,7 @@ title: User Permissions
InvenTree provides access control to various features and data, by assigning each *user* to one (or more) *groups* which have multiple *roles* assigned.
!!! info "Superuser"
The superuser account is afforded *all* permissions across an InvenTree installation. This includes the admin interface, web interface, and API.
The superuser account is afforded *all* permissions across an InvenTree installation. This includes the [Database Admin interface](./db_admin.md), web interface, and API.
### User
@@ -54,7 +54,7 @@ Within each role, there are four levels of available permissions:
## Dangerous User Flags
In addition to the above permissions, there are two special flags that can be assigned to a user:
- **Staff** - A user with the *staff* flag is able to access the admin interface, and can trigger dangerous actions that might have a security impact such as changing parsable files on the server (templates / reports / plugins). Some of these actions require the *admin* role to be assigned as well.
- **Staff** - A user with the *staff* flag is able to access the [Database Admin interface](./db_admin.md), and can trigger dangerous actions that might have a security impact such as changing parsable files on the server (templates / reports / plugins). Some of these actions require the *admin* role to be assigned as well.
- **Superuser** - A user with the *superuser* flag is able to access and change all data and functions of InvenTree. A superuser can modify and access all data that the InvenTree installation / server has access to - including shell access on the server OS itself. This is a very powerful flag, and should be used with caution.
It is strongly recommended to register any users with staff / superuser flags with strong MFA methods to reduce the risk of unauthorized access. These accounts should be used with caution, and should not be used for day-to-day operations.
@@ -62,11 +62,11 @@ It is strongly recommended to register any users with staff / superuser flags wi
Practicing account tiering is strongly recommended.
## Admin Interface Permissions
## Database Admin Permissions
If a user does not have the required permissions to perform a certain action in the admin interface, those options not be displayed.
If a user does not have the required permissions to perform a certain action in the [Database Admin interface](./db_admin.md), those options will not be displayed.
If a user is expecting a certain option to be available in the admin interface, but it is not present, it is most likely the case that the user does not have those permissions assigned.
If a user is expecting a certain option to be available in the Database Admin interface, but it is not present, it is most likely the case that the user does not have those permissions assigned.
## Web Interface Permissions
+2 -2
View File
@@ -24,6 +24,6 @@ The Django Q work must run separately to the web server. This is started as a se
If the worker is not running, a warning indicator is displayed in the InvenTree menu bar.
## Admin Interface
## Admin Center
Scheduled tasks can be viewed in the InvenTree admin interface.
Scheduled, pending and failed tasks can be viewed in the [Admin Center](./admin.md#admin-center), under the *Background Tasks* section.
+1
View File
@@ -25,6 +25,7 @@ The *Display Settings* screen shows general display configuration options:
{{ usersetting("BARCODE_IN_FORM_FIELDS") }}
{{ usersetting("DATE_DISPLAY_FORMAT") }}
{{ usersetting("FORMS_CLOSE_USING_ESCAPE") }}
{{ usersetting("ENABLE_PREVIEW_PANEL") }}
{{ usersetting("DISPLAY_STOCKTAKE_TAB") }}
{{ usersetting("SHOW_FULL_CATEGORY_IN_TABLES")}}
{{ usersetting("SHOW_BOM_SUBASSEMBLY_LEVELS")}}
+1 -1
View File
@@ -65,7 +65,7 @@ The following basic options are available:
{{ configsetting("INVENTREE_SITE_URL") }} Specify a fixed site URL |
{{ configsetting("INVENTREE_TIMEZONE") }} Server timezone |
{{ configsetting("INVENTREE_ADMIN_ENABLED") }} Enable the [django administrator interface]({% include "django.html" %}/ref/contrib/admin/) |
{{ configsetting("INVENTREE_ADMIN_URL") }} URL for accessing [admin interface](../settings/admin.md) |
{{ configsetting("INVENTREE_ADMIN_URL") }} URL for accessing the [Database Admin interface](../settings/db_admin.md) |
{{ configsetting("INVENTREE_LANGUAGE") }} Default language |
{{ configsetting("INVENTREE_AUTO_UPDATE") }} Database migrations will be run automatically |
+90
View File
@@ -0,0 +1,90 @@
---
title: Stock Disassembly
---
## Stock Disassembly
A stock item of an [assembly](../part/index.md#assembly) part can be *disassembled* back into its component parts, based on the [Bill of Materials](../manufacturing/bom.md) (BOM) for that part. This is the reverse of building an assembly - instead of consuming components to create an assembled item, the assembled item is broken back down into its constituent components.
This is useful in a number of scenarios, for example:
- An assembly is being reworked or scrapped, and the still-usable components need to be returned to stock
- A supplier ships a "bundled" or "kit" product as a single line item, which needs to be split apart into its individual components before the parts can be used or sold separately (see below)
- Correcting an assembly which was built or received in error
Disassembly is only available for a stock item whose part is marked as an *assembly*, and requires that the part has at least one [BOM line item](../manufacturing/bom.md#bom-line-items) defined.
To disassemble a stock item, navigate to the stock item detail page and select the *Disassemble* option from the actions menu. This requires that the user has the *Stock: Add* permission, and that the stock item is currently [in stock](./status.md).
{{ image("stock/stock_options.png", "Stock Options") }}
### Disassembly Form
The disassembly form is pre-populated with one line per BOM line item defined for the part, based on the quantity of assemblies being disassembled. Any line item marked as [consumable](../manufacturing/bom.md#consumable-bom-line-items) (whether the BOM line itself or its underlying part is marked consumable), or which points to a [virtual](../part/index.md) part, is excluded, as these components are not expected to be tracked as physical stock. This exclusion is enforced by the API - such a BOM line cannot be submitted for disassembly, even if referenced directly.
For each line, the following values may be adjusted:
| Field | Description |
| --- | --- |
| Quantity | The total quantity of the component part to generate. This is automatically scaled as the top-level *Quantity* field is changed, unless the user has manually edited it |
| Location | An optional destination location for the generated stock item. If not specified, the component is placed in the same location as the disassembled item (or the default location specified at the top of the form) |
| Status | An optional [stock status](./status.md) to apply to the generated stock item. If not specified, the component is created with the default *OK* status |
| Unit Price | An optional purchase price to record against the generated stock item. If left blank, a price is calculated automatically (see below) |
A line item can be removed from the form entirely if that particular component is not required to be split out - for example, if it is being scrapped rather than returned to stock. A line cannot be removed if it has installed items associated with it (see below).
### Quantity
Only the *available* quantity of a stock item can be disassembled. A [serialized](./traceability.md#serial-numbers) stock item does not have an adjustable quantity - since it represents a single physical unit, it must always be disassembled in its entirety.
The original stock item is never deleted as a result of disassembly - its quantity is reduced by the disassembled amount, in order to preserve traceability. If a stock item is disassembled down to a zero quantity, it is retained in the database (in an *unavailable* state) rather than removed, even if the item has the [Delete on Deplete](./availability.md#delete-on-deplete) flag set. A serialized item cannot be reduced to a zero quantity, so in this case the original item is instead marked with a *Destroyed* status.
### Accounting for Installed Items
If the stock item being disassembled has other stock items [installed](../manufacturing/allocate.md#allocating-tracked-stock) within it (for example, tracked components that were installed during a build order), these installed items **must** be accounted for during disassembly:
- Each installed item is matched against a BOM line item, based on the component part (including any [substitute](../manufacturing/bom.md#substitute-bom-line-items) or [variant](../part/index.md#assembly) parts allowed for that line)
- Matched installed items are *uninstalled* directly, rather than being discarded and re-created - this preserves the original stock item, including its own tracking history, batch code, and purchase price
- The quantity requested for the matching BOM line is reduced by the quantity already covered by the installed item(s). A new stock item is only created for any remaining quantity - if the installed items fully cover the required quantity, no new stock item is created for that line
- Any installed item which does *not* match one of the selected BOM lines is still uninstalled (it cannot be left "installed" inside a smaller or non-existent parent), but its quantity is **not** subtracted from any line
Because installed items cannot be partially accounted for, **a stock item with any installed items must be disassembled in its entirety** - a partial disassembly (disassembling less than the full available quantity) is rejected if any items are currently installed.
The disassembly form displays a count of installed items against each matching BOM line, and lists any "leftover" installed items (which do not match a BOM line) in a separate warning panel.
### Automatic Cost Allocation
If the original stock item has a recorded purchase price, and no explicit *Unit Price* has been entered for the generated lines, InvenTree attempts to automatically apportion that cost across the newly generated components:
- The total cost (unit purchase price × disassembled quantity) is split across the lines, weighted by the existing [pricing](../part/pricing.md) data (average of minimum and maximum overall price) for each component part
- If pricing data is not available for *every* line, the cost is instead split evenly on a per-unit basis across all generated units
- Cost is only allocated across newly *created* stock items - any matched installed items retain their own existing purchase price, and are excluded from the cost split entirely
- If any line has an explicit *Unit Price* provided by the user, automatic cost allocation is skipped entirely, and prices are only applied where explicitly set
### Traceability
Disassembling a stock item generates a full audit trail:
- A `Disassembled into components` entry is added to the tracking history of the original stock item
- Each newly created component stock item receives a `Created from disassembly` tracking entry, referencing the original stock item
- The *batch code* and source *purchase order* of the original stock item are copied directly to each generated component
- If the original stock item was generated by a build order, that build order cannot be directly copied to the new component (since the component was not actually built by that order) - instead, it is recorded as a reference within the `Created from disassembly` tracking entry
### Purchasing Bundled Items
Some suppliers only sell a group of components as a single bundled or "kit" product, rather than as individual purchasable line items. Such a bundle can be modelled as an assembly part, purchased and received as a single stock item, and later disassembled into its individual components - with purchase price and traceability data automatically apportioned across the generated components, as described above.
Refer to the [Bundled Items](../purchasing/purchase_order.md#bundled-items) documentation for a full description of how to set this up.
### Enforced Limitations
The following limitations are enforced when disassembling a stock item:
- The part associated with the stock item must be marked as an *assembly*
- The stock item must currently be [in stock](./status.md) - for example, it cannot be allocated to a sales order, installed in another assembly, or already fully consumed
- At least one BOM line item must be selected for disassembly
- The disassembly quantity cannot exceed the available quantity of the stock item
- A serialized stock item must be disassembled in its entirety (its quantity cannot be partially reduced)
- Each BOM line may only be referenced once per disassembly operation
- A selected BOM line must be a valid line item for the part associated with the stock item
- If the stock item has any installed items, it must be disassembled in its entirety
+25
View File
@@ -40,6 +40,31 @@ This view displays all tracking entries associated with any stock item linked to
!!! info "Deleted Stock Items"
Even if a stock item is deleted from the system, the associated stock tracking entries are retained for historical reference. They will be visible in the part tracking history, but not in the stock item tracking history (as the stock item itself has been deleted).
## Installed Stock Items
A stock item can be *installed* inside another stock item, forming a parent/child relationship between the two. This is used to represent physical assembly - for example, a serialized PCB assembly which has been fitted with a tracked sub-component, such as a wireless module or a pre-programmed IC.
An installed stock item is no longer available for regular stock actions (it cannot be moved, allocated to an order, or built into another assembly) while it remains installed - it is only accessible "through" its parent item.
### How Items Become Installed
There are two ways that a stock item can become installed inside another:
- **Tracked build allocation** - when a [tracked BOM item](../manufacturing/allocate.md#allocating-tracked-stock) is allocated to a build order and the build output is completed, the allocated stock item is automatically installed into the completed build output
- **Manual installation** - from the *Stock Item Detail* page, a user can manually install one stock item into another, using the *Install Item* action. By default, the selected item's part must appear in the [Bill of Materials](../manufacturing/bom.md) of the parent item's part - this check can be disabled using the {{ globalsetting("STOCK_ENFORCE_BOM_INSTALLATION", short=True) }} setting
### Viewing Installed Items
Any stock items installed within a particular stock item are displayed on the *Installed Items* tab of the *Stock Item Detail* page.
By default, installed stock items are hidden from general stock item tables (as they are not directly available for use) - this can be changed using the {{ globalsetting("STOCK_SHOW_INSTALLED_ITEMS", short=True) }} setting.
### Removing Installed Items
An installed stock item can be removed (uninstalled) from its parent using the *Uninstall Item* action, which returns the item to a selected stock location and makes it available for regular stock actions once more.
Additionally, installed items are automatically uninstalled when the parent item is [disassembled](./disassemble.md#accounting-for-installed-items) into its component parts - refer to that page for a detailed description of how installed items are matched against Bill of Materials line items during disassembly.
## Stock Tracking Settings
There are a number of configuration options available for controlling the behavior of stock tracking functionality in the [system settings view](../settings/global.md):
+9 -2
View File
@@ -91,7 +91,12 @@ nav:
- Privacy: privacy.md
- Concepts:
- Terminology: concepts/terminology.md
- User Interface: concepts/user_interface.md
- User Interface:
- Overview: concepts/ui/index.md
- Tables: concepts/ui/tables.md
- Preview Panels: concepts/ui/preview_panels.md
- Forms: concepts/ui/forms.md
- Global Search: concepts/ui/global_search.md
- Threat Model: concepts/threat_model.md
- Physical Units: concepts/units.md
- Companies: concepts/company.md
@@ -147,6 +152,7 @@ nav:
- Stock Tracking: stock/tracking.md
- Stock Status: stock/status.md
- Adjusting Stock: stock/adjust.md
- Stock Disassembly: stock/disassemble.md
- Stock Expiry: stock/expiry.md
- Stock Ownership: stock/owner.md
- Test Results: stock/test.md
@@ -186,7 +192,8 @@ nav:
- Global Settings: settings/global.md
- User Settings: settings/user.md
- Reference Patterns: settings/reference.md
- Admin Interface: settings/admin.md
- Admin Center: settings/admin.md
- Database Admin Interface: settings/db_admin.md
- Setup:
- User Permissions: settings/permissions.md
- Single Sign on: settings/SSO.md
+21 -2
View File
@@ -1,16 +1,35 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 517
INVENTREE_API_VERSION = 523
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v517 -> 2026-07-06 : https://github.com/inventree/InvenTree/pull/11971
v523 -> 2026-07-06 : https://github.com/inventree/InvenTree/pull/11971
- Removes direct "notes" field from any models which previously supported markdown notes
- Adds a generic "Note" model which can be attached to any model type via a generic foreign key relationship
- Allow multiple notes to be attached to a single object, and for notes to be created / edited / deleted via the API
v522 -> 2026-07-14 : https://github.com/inventree/InvenTree/pull/12388
- Adds "unique" field to the ParameterTemplate model
v521 -> 2026-07-12 : https://github.com/inventree/InvenTree/pull/12360
- Removes the MPTT mixin from the StockItem model, and removes the self-referential tree structure from the database.
v520 -> 2026-07-11 : https://github.com/inventree/InvenTree/pull/12310
- Adds new "disassemble" API endpoint for stock items
- Allows a stock item to be broken down into component parts, based on its Bill of Materials
v519 -> 2026-07-09 : https://github.com/inventree/InvenTree/pull/TODO
- Adds optional "roles" and "permissions" fields to the /user/me/ API endpoint, via the "?roles=true" query parameter
v518 -> 2026-07-09 : https://github.com/inventree/InvenTree/pull/12341
- Enable import of internal part prices via the API
v517 -> 2026-07-08 : https://github.com/inventree/InvenTree/pull/12336
- Fix currency code options for the PartPricing model and API endpoints
v516 -> 2026-07-03 : https://github.com/inventree/InvenTree/pull/12295
- Adds "consumable" field to the Part model and API endpoints
+40 -1
View File
@@ -14,6 +14,7 @@ from djmoney.models.fields import MoneyField as ModelMoneyField
from djmoney.models.validators import MinMoneyValidator
from rest_framework.fields import URLField as RestURLField
from rest_framework.fields import empty
from rest_framework.relations import PrimaryKeyRelatedField
import InvenTree.helpers
import InvenTree.ready
@@ -48,6 +49,42 @@ class InvenTreeRestURLField(RestURLField):
return super().run_validation(data=data)
class PrefetchedPrimaryKeyRelatedField(PrimaryKeyRelatedField):
"""A PrimaryKeyRelatedField which resolves against a pre-fetched {pk: instance} map.
PrimaryKeyRelatedField normally issues one .get() query per list entry when used
inside a many=True nested serializer - for large lists (hundreds of related objects)
that becomes an O(n) query cost just to validate the request. The parent serializer
should instead bulk-fetch all referenced objects in a single query and stash the
{pk: instance} map in self.context[cache_key] (typically from an overridden
to_internal_value()); this field then does an O(1) dict lookup instead of hitting
the database.
Falls back to the default per-item query if no cache has been populated (or the pk
is missing from it), so this field remains safe to use standalone - e.g. in tests
constructing the child serializer directly, or for a pk that's genuinely invalid.
"""
def __init__(self, cache_key: str, **kwargs):
"""Store the context key under which the parent serializer stashes its prefetch cache."""
self.cache_key = cache_key
super().__init__(**kwargs)
def to_internal_value(self, data):
"""Resolve 'data' (a raw pk value) against the prefetch cache, if available."""
cache = self.context.get(self.cache_key)
try:
pk = int(data)
except (TypeError, ValueError):
pk = None
if not cache or pk not in cache:
return super().to_internal_value(data)
return cache[pk]
class InvenTreeURLField(models.URLField):
"""Custom URL field which has custom scheme validators."""
@@ -100,7 +137,9 @@ def money_kwargs(**kwargs):
kwargs['decimal_places'] = 6
if 'currency_choices' not in kwargs:
kwargs['currency_choices'] = currency_code_mappings()
# Pass the function itself (not the evaluated result) so that the
# available currency options are resolved dynamically.
kwargs['currency_choices'] = currency_code_mappings
if InvenTree.ready.isRunningMigrations():
# During migrations, avoid setting a default currency
@@ -0,0 +1,66 @@
"""Database helper functions for InvenTree."""
import uuid
from typing import Optional
from django.db import transaction
from django.db.models import QuerySet
@transaction.atomic
def bulk_create_and_fetch(
model, items, id_field: str = 'pk', filters: Optional[dict] = None
) -> QuerySet:
"""Bulk create items in the database, and return a queryset of the created items.
Arguments:
model: The Django model class to create instances of.
items: A list of dictionaries containing the data for each item to be created.
id_field: The name of the field to use as the unique identifier for the created items.
filters: Optional dictionary of filters to apply when fetching the created items.
Returns:
A Django QuerySet containing the created items.
This helper method is required because the Django bulk_create() method
does not guarantee that the ID values of the created items will be populated in the returned objects.
In particular, MySQL does not support returning the ID values of bulk created items.
So, we provide temporary metadata to the created items,
which can be used to fetch the created items from the database.
Assumptions:
- The provided model type has a "metadata" attribute which can be overloaded for this purpose
- No "metadata" is provided in the input items, as this will be overwritten by the method
- The model type has an incrementing ID field (default: "pk")
"""
bulk_create_id = uuid.uuid4().hex
# Generate temporary metadata for bulk fetching
metadata = {'bulk_create_id': bulk_create_id}
lookup_filters = dict(filters) if filters else {}
lookup_filters['metadata__bulk_create_id'] = bulk_create_id
if id_field:
# Find the "most recent" item in the database, to set a search floor
if instance := model.objects.order_by(f'-{id_field}').first():
lookup_filters[f'{id_field}__gt'] = getattr(instance, id_field)
# Overwrite the metadata values
for item in items:
item.metadata = metadata
model.objects.bulk_create(items, batch_size=500)
instances = model.objects.filter(**lookup_filters)
pks = list(instances.values_list(id_field or 'pk', flat=True))
# Override the metadata values to remove the temporary bulk_create_id
instances.update(metadata=None)
# Fetch the newly created items (by primary key, as the metadata filter no longer matches)
return model.objects.filter(**{f'{id_field or "pk"}__in': pks})
@@ -43,16 +43,6 @@ class Command(BaseCommand):
except Exception:
logger.info('Error rebuilding PartCategory objects')
# StockItem model
try:
logger.info('Rebuilding StockItem objects')
from stock.models import StockItem
StockItem.objects.rebuild()
except Exception:
logger.info('Error rebuilding StockItem objects')
# StockLocation model
try:
logger.info('Rebuilding StockLocation objects')
+16 -3
View File
@@ -596,12 +596,21 @@ class InvenTreeParameterMixin(InvenTreePermissionCheckMixin, models.Model):
content_type = ContentType.objects.get_for_model(self.__class__)
template_ids = [parameter.template.pk for parameter in other.parameters.all()]
# Skip any parameters which are linked to a template with a uniqueness requirement,
# as copying these values would create conflicting (duplicate) values
copyable_parameters = [
parameter
for parameter in other.parameters.all().select_related('template')
if parameter.template.unique
== common.models.ParameterTemplate.UniqueOptions.NONE
]
template_ids = [parameter.template.pk for parameter in copyable_parameters]
# Remove all conflicting parameters first
self.parameters_list.filter(template__pk__in=template_ids).delete()
for parameter in other.parameters.all():
for parameter in copyable_parameters:
parameter.pk = None
parameter.model_id = self.pk
parameter.model_type = content_type
@@ -1543,11 +1552,15 @@ def after_failed_task(sender, instance: Task, created: bool, **kwargs):
# Create a new Error object associated with this failed task
# This will, in turn, trigger a notification to staff users via the Error post_save signal
message = f"Task '{instance.func} ({instance.pk})' failed after {n} attempts"
logger.error(message)
log_error(
'task_failure',
scope='worker',
error_name='Task Failure',
error_info=f"Task '{instance.pk}' failed after {n} attempts",
error_info=message,
error_data=str(instance.result) if instance.result else '',
)
+7 -1
View File
@@ -223,12 +223,18 @@ def postprocess_schema_enums(result, generator, **kwargs):
"""Custom patch to ignore some drf-spectacular warnings.
- Some warnings are unavoidable due to the way that InvenTree implements generic relationships (via ContentType).
- Some warnings are unavoidable due to the way that InvenTree implements custom (database-editable) status codes:
multiple serializers legitimately expose a 'status' field backed by the same dynamic StockStatus choice set
(e.g. stock adjustment, receiving a purchase order line, disassembling a stock item), and drf-spectacular
cannot settle on a single stable name for the shared, runtime-dependent choice set.
- The cleanest way to handle this appears to be to override the 'warn' function from drf-spectacular.
Ref: https://github.com/inventree/InvenTree/pull/10699
"""
ignore_patterns = [
'enum naming encountered a non-optimally resolvable collision for fields named "model_type"'
'enum naming encountered a non-optimally resolvable collision for fields named "model_type"',
'enum naming encountered a non-optimally resolvable collision for fields named "status"',
'encountered multiple names for the same choice set (StatusCustomKeyEnum)',
]
if any(pattern in msg for pattern in ignore_patterns):
+183 -3
View File
@@ -1,10 +1,13 @@
"""Functions for tasks and a few general async tasks."""
import contextvars
import json
import os
import re
import warnings
from collections import defaultdict
from collections.abc import Callable
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional
@@ -13,7 +16,7 @@ from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import AppRegistryNotReady, ValidationError
from django.core.management import call_command
from django.db import DEFAULT_DB_ALIAS, connections
from django.db import DEFAULT_DB_ALIAS, connections, transaction
from django.db.migrations.executor import MigrationExecutor
from django.db.utils import NotSupportedError, OperationalError, ProgrammingError
from django.utils import timezone
@@ -201,6 +204,86 @@ def check_existing_task(taskname, group: str, *args, **kwargs) -> Optional[str]:
return task_id
# Context-local batch of pending offload_task() calls (see batch_offload_tasks())
_task_batch: contextvars.ContextVar = contextvars.ContextVar('task_batch', default=None)
class TaskBatch:
"""Collects offload_task() calls made within a batch_offload_tasks() scope.
Entries are grouped by (taskname, group, force_async), so that each distinct
combination triggered within the batch is flushed via its own bulk_offload_task() call.
"""
def __init__(self):
"""Initialize an empty batch."""
self.entries: dict[tuple, list] = defaultdict(list)
def add(
self, taskname, group: str, force_async: bool, args: tuple, kwargs: dict
) -> None:
"""Record a single offload_task() call against this batch."""
self.entries[taskname, group, force_async].append((args, kwargs))
def flush(self) -> None:
"""Fire a bulk_offload_task() call for each (taskname, group, force_async) group collected so far."""
entries, self.entries = self.entries, defaultdict(list)
for (taskname, group, force_async), task_entries in entries.items():
bulk_offload_task(
taskname, task_entries, group=group, force_async=force_async
)
@contextmanager
def batch_offload_tasks():
"""Batch offload_task() calls made within this scope into bulk_offload_task() calls.
Any offload_task() call made (directly, or indirectly via a nested function call) while
this context is active is queued instead of immediately offloaded - *except* for calls
which pass force_sync=True, which always run immediately and synchronously as before.
Excluding these is necessary because a forced-sync call is relied upon to have completed,
with its side effects visible, by the time offload_task() returns control to the caller -
deferring it would silently break that contract.
The queued calls are flushed - grouped by (taskname, group, force_async), one
bulk_offload_task() call per group - when the current database transaction commits (or
immediately, if no transaction is active). If the transaction is instead rolled back, the
queued calls are discarded, rather than being fired for a write that never happened.
Note: bulk_offload_task() does not perform duplicate-task checking, unlike offload_task()'s
default (check_duplicates=True) behavior - queued calls are never deduplicated, regardless
of the check_duplicates value passed to offload_task().
A batched offload_task() call always returns True immediately, rather than a task ID -
the actual task ID is not known until the batch is flushed, possibly well after the
call returns. Callers which depend on the returned task ID should not use this context.
Nesting is not supported: a nested batch_offload_tasks() call reuses the outer batch,
and only the outermost call schedules a flush.
This mirrors plugin.base.event.events.batch_events() and stock.models.batch_tracking_entries()
- see batch_events()'s docstring for the reasoning behind the on-commit flush and the
context-local (rather than parameter-based) design.
Yields:
The current TaskBatch instance
"""
if _task_batch.get() is not None:
# Already inside a batch - extend it, rather than creating a nested one
yield _task_batch.get()
return
batch = TaskBatch()
token = _task_batch.set(batch)
try:
yield batch
finally:
_task_batch.reset(token)
transaction.on_commit(batch.flush)
def offload_task(
taskname,
*args,
@@ -224,11 +307,18 @@ def offload_task(
Returns:
str | bool: Task ID if the task was offloaded, True if ran synchronously, False otherwise
"""
from InvenTree.exceptions import log_error
# Extract group information from kwargs
group = kwargs.pop('group', 'inventree')
if not force_sync and (batch := _task_batch.get()) is not None:
# A batch_offload_tasks() context is active - queue this task rather than
# offloading it immediately (force_sync=True calls never reach this branch -
# see batch_offload_tasks() for why they are excluded from batching)
batch.add(taskname, group, force_async, args, kwargs)
return True
from InvenTree.exceptions import log_error
try:
import importlib
@@ -323,6 +413,96 @@ def offload_task(
return True
def bulk_offload_task(
taskname,
entries: list,
group: str = 'inventree',
force_sync: bool = False,
force_async: bool = False,
) -> bool:
"""Queue the same background task many times, in a single bulk database write.
Equivalent to calling offload_task() once per (args, kwargs) pair in 'entries', but
writes all of the queued tasks to the django-q2 ORM broker table (OrmQ) in a single
bulk_create() call, rather than one INSERT per task.
Note: InvenTree always configures django-q2 to use the ORM broker (see
InvenTree.setting.worker.get_worker_config), so this does not need to handle any
other broker backend.
Arguments:
taskname: The name of the task to be run, in the format 'app.module.function'
entries: List of (args, kwargs) tuples, one per task instance to queue
group: The task group to assign to each queued task
force_sync: If True, run all tasks synchronously (even if workers are running)
force_async: If True, force all tasks to be queued (even if workers are not running)
Returns:
bool: True if the tasks were queued (or run synchronously), False otherwise
"""
if not entries:
return False
try:
from django_q.brokers import get_broker
from django_q.humanhash import uuid
from django_q.models import OrmQ
from django_q.signing import SignedPackage
from InvenTree.status import is_worker_running
except AppRegistryNotReady: # pragma: no cover
logger.warning(
"Could not offload bulk task '%s' - app registry not ready", taskname
)
force_sync = True
except (OperationalError, ProgrammingError): # pragma: no cover
raise_warning(f"Could not offload bulk task '{taskname}' - database not ready")
force_sync = True
if not force_async and (force_sync or not is_worker_running()):
# Workers are not available - fall back to running each task synchronously
for args, kwargs in entries:
offload_task(
taskname,
*args,
group=group,
force_sync=True,
check_duplicates=False,
**kwargs,
)
return True
broker = get_broker()
tasks = []
for args, kwargs in entries:
name, task_id = uuid()
task = {
'id': task_id,
'name': name,
'func': taskname,
'args': args,
'kwargs': kwargs,
'group': group,
'started': timezone.now(),
}
tasks.append(
OrmQ(
key=broker.list_key or 'inventree',
payload=SignedPackage.dumps(task),
lock=timezone.now(),
)
)
OrmQ.objects.bulk_create(tasks)
return True
def get_queued_task(task_id: str):
"""Find the task in the queue, if it exists.
@@ -1,5 +1,7 @@
"""Tests for custom InvenTree management commands."""
import os
import subprocess
from pathlib import Path
from django.conf import settings
@@ -15,6 +17,48 @@ from InvenTree.config import get_testfolder_dir
class CommandTestCase(TestCase):
"""Test case for custom management commands."""
def test_makemigrations_currency_overrides_no_changes(self):
"""Ensure currency list changes do not cause migration drift."""
currency_sets = ['USD,EUR,GBP', 'JPY,CNY,KRW']
for currency_codes in currency_sets:
with self.subTest(currency_codes=currency_codes):
old_value = os.environ.get('INVENTREE_CURRENCY_CODES')
try:
os.environ['INVENTREE_CURRENCY_CODES'] = currency_codes
project_dir = Path(__file__).resolve().parents[1]
result = subprocess.run(
[
'python3',
'manage.py',
'makemigrations',
'--check',
'--dry-run',
'--verbosity',
'0',
],
cwd=project_dir,
env=os.environ.copy(),
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
self.fail(
'makemigrations reported schema changes '
f'for INVENTREE_CURRENCY_CODES={currency_codes}\n'
f'stdout:\n{result.stdout}\n'
f'stderr:\n{result.stderr}'
)
finally:
if old_value is None:
os.environ.pop('INVENTREE_CURRENCY_CODES', None)
else:
os.environ['INVENTREE_CURRENCY_CODES'] = old_value
def test_schema(self):
"""Test the schema generation command."""
output = call_command('schema', file='schema.yml', verbosity=0)
@@ -0,0 +1,163 @@
"""Functional tests for the InvenTree.helpers_db module."""
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from company.models import Company, Contact
from InvenTree.helpers_db import bulk_create_and_fetch
from order.models import PurchaseOrder
from part.models import Part
from stock.models import StockItem
class BulkCreateAndFetchTest(TestCase):
"""Tests for the bulk_create_and_fetch helper function."""
def test_basic(self):
"""Created items are returned with populated, unique primary keys."""
company = Company.objects.create(name='ACME')
items = [Contact(company=company, name=f'Contact {idx}') for idx in range(10)]
result = bulk_create_and_fetch(Contact, items)
self.assertEqual(result.count(), 10)
pks = [c.pk for c in result]
# Every item has a valid, unique primary key
self.assertTrue(all(pk is not None for pk in pks))
self.assertEqual(len(pks), len(set(pks)))
# Returned items exactly match what is actually in the database
db_pks = set(
Contact.objects.filter(company=company).values_list('pk', flat=True)
)
self.assertEqual(set(pks), db_pks)
# Temporary bulk-create metadata should have been cleared again
for contact in Contact.objects.filter(pk__in=pks):
self.assertIsNone(contact.metadata)
def test_with_filters(self):
"""Additional filters correctly scope the returned queryset."""
company_a = Company.objects.create(name='Company A')
company_b = Company.objects.create(name='Company B')
items_a = [Contact(company=company_a, name=f'A{i}') for i in range(3)]
items_b = [Contact(company=company_b, name=f'B{i}') for i in range(4)]
result_a = bulk_create_and_fetch(
Contact, items_a, filters={'company': company_a}
)
result_b = bulk_create_and_fetch(
Contact, items_b, filters={'company': company_b}
)
self.assertEqual(result_a.count(), 3)
self.assertEqual(result_b.count(), 4)
self.assertTrue(all(c.company_id == company_a.pk for c in result_a))
self.assertTrue(all(c.company_id == company_b.pk for c in result_b))
def test_ignores_pre_existing_rows(self):
"""The 'search floor' should exclude pre-existing rows in the table."""
company = Company.objects.create(name='ACME')
# Pre-existing item, *not* created via the helper
Contact.objects.create(company=company, name='Existing')
items = [Contact(company=company, name=f'New {i}') for i in range(2)]
result = bulk_create_and_fetch(Contact, items)
self.assertEqual(result.count(), 2)
self.assertNotIn('Existing', [c.name for c in result])
def test_empty_list(self):
"""Calling with an empty list of items should not raise, and return no items."""
result = bulk_create_and_fetch(Contact, [])
self.assertEqual(result.count(), 0)
def test_does_not_mutate_caller_filters(self):
"""The caller-provided 'filters' dict should not be mutated as a side effect."""
company = Company.objects.create(name='ACME')
filters = {'company': company}
items = [Contact(company=company, name='Contact')]
bulk_create_and_fetch(Contact, items, filters=filters)
def test_purchase_order(self):
"""The helper works for the PurchaseOrder model."""
supplier = Company.objects.create(name='Supplier Co', is_supplier=True)
references = [f'PO-{idx:04d}' for idx in range(10)]
items = [
PurchaseOrder(supplier=supplier, reference=ref, description=f'Order {ref}')
for ref in references
]
result = bulk_create_and_fetch(PurchaseOrder, items)
self.assertEqual(result.count(), 10)
pks = [o.pk for o in result]
self.assertTrue(all(pk is not None for pk in pks))
self.assertEqual(len(pks), len(set(pks)))
self.assertEqual(
set(result.values_list('reference', flat=True)), set(references)
)
for order in PurchaseOrder.objects.filter(pk__in=pks):
self.assertIsNone(order.metadata)
def test_stock_item(self):
"""The helper works for the StockItem model."""
part = Part.objects.create(name='Widget', description='A widget')
items = [StockItem(part=part, quantity=idx + 1) for idx in range(10)]
result = bulk_create_and_fetch(StockItem, items)
self.assertEqual(result.count(), 10)
pks = [si.pk for si in result]
self.assertTrue(all(pk is not None for pk in pks))
self.assertEqual(len(pks), len(set(pks)))
self.assertEqual(
sorted(result.values_list('quantity', flat=True)),
sorted(idx + 1 for idx in range(10)),
)
for stock_item in StockItem.objects.filter(pk__in=pks):
self.assertIsNone(stock_item.metadata)
def _create_and_count_queries(self, n: int) -> int:
"""Bulk-create 'n' Contact items, and return the number of queries used."""
company = Company.objects.create(name=f'Company {n}')
items = [Contact(company=company, name=f'Contact {i}') for i in range(n)]
with CaptureQueriesContext(connection) as ctx:
result = bulk_create_and_fetch(Contact, items)
self.assertEqual(result.count(), n)
MAX_QUERIES = 25 if n > 100 else 10
self.assertLess(len(ctx.captured_queries), MAX_QUERIES)
return len(ctx.captured_queries)
def test_query_count_is_constant(self):
"""The number of queries used should not depend on the number of created items."""
small_query_count = self._create_and_count_queries(5)
large_query_count = self._create_and_count_queries(100)
self.assertEqual(small_query_count, large_query_count)
# Perform a very large bulk-create - this will be batched
self._create_and_count_queries(2000)
@@ -6,6 +6,7 @@ from datetime import timedelta
from django.conf import settings
from django.contrib.auth.models import User
from django.core.management import call_command
from django.db import transaction
from django.db.utils import NotSupportedError
from django.test import TestCase
from django.utils import timezone
@@ -411,3 +412,175 @@ class InvenTreeTaskTests(PluginRegistryMixin, TestCase):
# 20 more tasks should have been added
self.assertEqual(OrmQ.objects.count(), 41)
def test_bulk_offload(self):
"""Test the bulk_offload_task function."""
# Start with a blank slate
OrmQ.objects.all().delete()
entries = [
((idx, idx + 1), {'animal': f'animal_{idx}', 'count': idx})
for idx in range(10)
]
# Queuing all 10 tasks should only take a single database write (bulk_create)
with self.assertNumQueries(1):
result = InvenTree.tasks.bulk_offload_task(
'dummy_module.dummy_function', entries, force_async=True
)
self.assertTrue(result)
self.assertEqual(OrmQ.objects.count(), 10)
# Read out the pending tasks, and check that the args / kwargs match
queued_tasks = OrmQ.objects.all().order_by('id')
for task, (args, kwargs) in zip(queued_tasks, entries, strict=True):
self.assertEqual(task.func(), 'dummy_module.dummy_function')
self.assertEqual(task.group(), 'inventree')
self.assertEqual(task.args(), args)
self.assertEqual(task.kwargs(), kwargs)
class TaskBatchTests(TestCase):
"""Unit tests for the batch_offload_tasks() context manager."""
def setUp(self):
"""Start each test with an empty task queue."""
super().setUp()
OrmQ.objects.all().delete()
def test_tasks_queued_and_flushed_on_commit(self):
"""Tasks offloaded inside batch_offload_tasks() are queued and flushed as one bulk write on commit."""
with self.captureOnCommitCallbacks(execute=True):
with transaction.atomic(), InvenTree.tasks.batch_offload_tasks():
for idx in range(10):
InvenTree.tasks.offload_task(
'dummy_module.dummy_function',
idx,
force_async=True,
animal=f'animal_{idx}',
)
# Nothing should be queued yet - the batch only flushes on commit
self.assertEqual(OrmQ.objects.count(), 0)
self.assertEqual(OrmQ.objects.count(), 10)
queued_tasks = OrmQ.objects.all().order_by('id')
for idx, task in enumerate(queued_tasks):
self.assertEqual(task.func(), 'dummy_module.dummy_function')
self.assertEqual(task.group(), 'inventree')
self.assertEqual(task.args(), (idx,))
self.assertEqual(task.kwargs(), {'animal': f'animal_{idx}'})
def test_tasks_grouped_by_name_and_group(self):
"""Tasks with different (taskname, group) combinations are flushed as separate bulk writes."""
with self.captureOnCommitCallbacks(execute=True):
with transaction.atomic(), InvenTree.tasks.batch_offload_tasks():
for idx in range(5):
InvenTree.tasks.offload_task(
'dummy_module.task_a', idx, force_async=True, group='alpha'
)
for idx in range(3):
InvenTree.tasks.offload_task(
'dummy_module.task_b', idx, force_async=True, group='beta'
)
self.assertEqual(OrmQ.objects.count(), 8)
self.assertEqual(
sum(
1
for t in OrmQ.objects.all()
if t.func() == 'dummy_module.task_a' and t.group() == 'alpha'
),
5,
)
self.assertEqual(
sum(
1
for t in OrmQ.objects.all()
if t.func() == 'dummy_module.task_b' and t.group() == 'beta'
),
3,
)
def test_tasks_discarded_on_rollback(self):
"""Tasks queued in a batch are discarded, not fired, if the transaction rolls back."""
with self.captureOnCommitCallbacks(execute=True):
try:
with transaction.atomic(), InvenTree.tasks.batch_offload_tasks():
InvenTree.tasks.offload_task(
'dummy_module.dummy_function', force_async=True
)
raise ValueError('boom')
except ValueError:
pass
self.assertEqual(OrmQ.objects.count(), 0)
def test_tasks_outside_batch_fire_immediately(self):
"""Tasks offloaded outside any batch_offload_tasks() context are unaffected - fired immediately."""
InvenTree.tasks.offload_task('dummy_module.dummy_function', force_async=True)
# No transaction commit or captureOnCommitCallbacks needed - it was never queued
self.assertEqual(OrmQ.objects.count(), 1)
def test_nested_batch_share_one_flush(self):
"""A nested batch_offload_tasks() call reuses the outer batch, rather than flushing twice."""
with self.captureOnCommitCallbacks(execute=True):
with transaction.atomic(), InvenTree.tasks.batch_offload_tasks():
InvenTree.tasks.offload_task(
'dummy_module.dummy_function', 1, force_async=True
)
with InvenTree.tasks.batch_offload_tasks():
InvenTree.tasks.offload_task(
'dummy_module.dummy_function', 2, force_async=True
)
self.assertEqual(OrmQ.objects.count(), 0)
self.assertEqual(OrmQ.objects.count(), 2)
def test_force_sync_excluded_from_batch(self):
"""force_sync=True calls bypass batch_offload_tasks() entirely and run immediately."""
calls = []
def sync_target():
calls.append('ran')
with self.captureOnCommitCallbacks(execute=True):
with transaction.atomic(), InvenTree.tasks.batch_offload_tasks():
InvenTree.tasks.offload_task(sync_target, force_sync=True)
# Ran immediately - not deferred to flush, and never touched the task queue
self.assertEqual(calls, ['ran'])
self.assertEqual(OrmQ.objects.count(), 0)
InvenTree.tasks.offload_task(
'dummy_module.dummy_function', force_async=True
)
# The (non-force_sync) async call is queued, not yet in OrmQ
self.assertEqual(OrmQ.objects.count(), 0)
# After commit: only the batched async call produced an OrmQ entry
self.assertEqual(OrmQ.objects.count(), 1)
self.assertEqual(calls, ['ran'])
def test_batched_calls_skip_duplicate_check(self):
"""Unlike individual offload_task() calls, batched calls are never deduplicated."""
with self.captureOnCommitCallbacks(execute=True):
with transaction.atomic(), InvenTree.tasks.batch_offload_tasks():
for _ in range(3):
InvenTree.tasks.offload_task(
'dummy_module.dummy_function_dup',
1,
2,
animal='cat',
force_async=True,
)
# All 3 identical calls were queued, unlike the non-batched dedup behavior
# exercised in InvenTreeTaskTests.test_duplicate_tasks
self.assertEqual(OrmQ.objects.count(), 3)
+4 -5
View File
@@ -104,10 +104,9 @@ class TreeFixtureTest(TestCase):
self.run_tree_test(Build)
def test_stock(self):
"""Test MPTT tree structure for Stock model."""
from stock.models import StockItem, StockLocation
"""Test MPTT tree structure for StockLocation model."""
from stock.models import StockLocation
self.run_tree_test(StockItem)
self.run_tree_test(StockLocation)
@@ -1102,7 +1101,7 @@ class CurrencyTests(TestCase):
update_successful = False
# Note: the update sometimes fails in CI, let's give it a few chances
for _ in range(10):
for idx in range(10):
InvenTree.tasks.update_exchange_rates()
rates = Rate.objects.all()
@@ -1114,7 +1113,7 @@ class CurrencyTests(TestCase):
else: # pragma: no cover
print('Exchange rate update failed - retrying')
print(f'Expected {currency_codes()}, got {[a.currency for a in rates]}')
time.sleep(1)
time.sleep(1 + idx)
self.assertTrue(update_successful)
+22 -3
View File
@@ -380,13 +380,23 @@ class TestQueryMixin:
):
"""Context manager to check that the number of queries is less than a certain value.
Arguments:
value: The maximum number of queries allowed
using: The database connection to use (default = 'default')
verbose: If True, print the queries to the console (default = False)
url: Optional URL to print in the output (default = None)
log_to_file: If True, log the queries to a file (default = False)
Yields:
The CaptureQueriesContext object, which contains the captured queries
Example:
with self.assertNumQueriesLessThan(10):
# Do some stuff
Ref: https://stackoverflow.com/questions/1254170/django-is-there-a-way-to-count-sql-queries-from-an-unit-test/59089020#59089020
"""
with CaptureQueriesContext(connections[using]) as context:
yield # your test will be run here
yield context # your test will be run here
n = len(context.captured_queries)
@@ -492,12 +502,16 @@ class InvenTreeAPITestCase(
expected_code = kwargs.pop('expected_code', None)
msg = kwargs.pop('msg', None)
max_queries = kwargs.pop('max_query_count', self.MAX_QUERY_COUNT)
max_query_count = kwargs.pop('max_query_count', self.MAX_QUERY_COUNT)
max_query_time = kwargs.pop('max_query_time', self.MAX_QUERY_TIME)
benchmark = kwargs.pop('benchmark', False)
t1 = time.time()
with self.assertNumQueriesLessThan(max_queries, url=url):
with (
self.assertNumQueriesLessThan(max_query_count, url=url) as context,
self.captureOnCommitCallbacks(execute=True),
):
response = method(url, data, **kwargs)
t2 = time.time()
@@ -512,6 +526,11 @@ class InvenTreeAPITestCase(
self.assertLessEqual(dt, max_query_time)
if benchmark:
print(
f"Benchmark @ '{url}': {len(context.captured_queries)} queries (of {max_query_count}) in {dt:.4f}s (of {max_query_time}s max)"
)
return response
def get(self, url, data=None, expected_code=200, **kwargs):
+2 -15
View File
@@ -133,27 +133,14 @@ def isInvenTreeDevelopmentVersion() -> bool:
return inventreeVersion().endswith('dev')
def inventreeDocsVersion() -> str:
"""Return the version string matching the latest documentation.
Development -> "latest"
Release -> "major.minor.sub" e.g. "0.5.2"
"""
if isInvenTreeDevelopmentVersion():
return 'latest'
return INVENTREE_SW_VERSION
def inventreeDocUrl() -> str:
"""Return URL for InvenTree documentation site."""
tag = inventreeDocsVersion()
return f'https://docs.inventree.org/en/{tag}'
return 'https://docs.inventree.org'
def inventreeAppUrl() -> str:
"""Return URL for InvenTree app site."""
return 'https://docs.inventree.org/en/stable/app/'
return 'https://docs.inventree.org/en/latest/app/'
def inventreeGithubUrl() -> str:
+34 -5
View File
@@ -1041,6 +1041,15 @@ class Build(
if not output:
raise ValidationError(_('No build output specified'))
# Re-check the state of the output itself:
# It may have changed since the scrap request was validated
# (e.g. a duplicated background task, or a concurrent request)
if not output.is_building:
raise ValidationError(_('Build output has already been completed'))
if output.build != self:
raise ValidationError(_('Build output does not match Build Order'))
# If quantity is not specified, assume the entire output quantity
if quantity is None:
quantity = output.quantity
@@ -1111,6 +1120,15 @@ class Build(
Raises:
ValidationError: If the build output cannot be completed, with an appropriate message
"""
# Re-check the state of the output itself:
# It may have changed since the completion request was validated
# (e.g. a duplicated background task, or a concurrent request)
if not output.is_building:
raise ValidationError(_('Build output has already been completed'))
if output.build != self:
raise ValidationError(_('Build output does not match Build Order'))
prevent_incomplete = get_global_setting(
'PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS'
)
@@ -1219,9 +1237,11 @@ class Build(
trigger_event(BuildEvents.OUTPUT_COMPLETED, id=output.pk, build_id=self.pk)
# Increase the completed quantity for this build
self.completed += output.quantity
self.save()
# Increment at the database level to prevent lost updates
# (multiple outputs may be completed concurrently)
self.completed = F('completed') + output.quantity
self.save(update_fields=['completed'])
self.refresh_from_db(fields=['completed'])
@transaction.atomic
def auto_allocate_stock(
@@ -2027,6 +2047,12 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
if quantity > item.quantity:
quantity = item.quantity
if quantity <= 0:
# There is nothing to consume or install:
# simply remove this (empty) allocation
self.delete()
return
# Split the allocated stock if there are more available than allocated
if item.quantity > quantity:
item = item.splitStock(quantity, None, user, notes=notes)
@@ -2056,8 +2082,11 @@ class BuildItem(InvenTree.models.InvenTreeMetadataModel):
)
# Increase the "consumed" count for the associated BuildLine
self.build_line.consumed += quantity
self.build_line.save()
# Increment at the database level to prevent lost updates
# (multiple allocations against the same BuildLine may complete concurrently)
self.build_line.consumed = F('consumed') + quantity
self.build_line.save(update_fields=['consumed'])
self.build_line.refresh_from_db(fields=['consumed'])
# Decrease the allocated quantity
self.quantity = max(0, self.quantity - quantity)
+5 -1
View File
@@ -1008,7 +1008,11 @@ class BuildAllocationSerializer(serializers.Serializer):
}
try:
if build_item := BuildItem.objects.filter(**params).first():
# Lock the row, so concurrent allocations cannot both read
# the same starting quantity (lost update)
if build_item := (
BuildItem.objects.select_for_update().filter(**params).first()
):
# Find an existing BuildItem for this stock item
# If it exists, increase the quantity
build_item.quantity += quantity
+68 -22
View File
@@ -115,9 +115,21 @@ def delete_build_outputs(build_id: int, output_ids: list, **kwargs):
with transaction.atomic():
for output_id in output_ids:
output = StockItem.objects.filter(pk=output_id).first()
if output:
build.delete_output(output)
# Lock the output row, and re-check that it is still "in production" -
# it may have been processed already (e.g. by a duplicated task)
output = StockItem.objects.select_for_update().filter(pk=output_id).first()
if not output:
continue
if not output.is_building:
logger.warning(
'Build output <%s> is no longer in production - skipping deletion',
output.pk,
)
continue
build.delete_output(output)
@tracer.start_as_current_span('scrap_build_outputs')
@@ -149,16 +161,33 @@ def scrap_build_outputs(
with transaction.atomic():
for item in outputs:
output = StockItem.objects.filter(pk=item['output_id']).first()
if output:
build.scrap_build_output(
output,
item.get('quantity'),
location,
user=user,
notes=notes,
discard_allocations=discard_allocations,
# Lock the output row, and re-check that it is still "in production" -
# it may have been processed already (e.g. by a duplicated task)
output = (
StockItem.objects
.select_for_update()
.filter(pk=item['output_id'])
.first()
)
if not output:
continue
if not output.is_building:
logger.warning(
'Build output <%s> is no longer in production - skipping scrap',
output.pk,
)
continue
build.scrap_build_output(
output,
item.get('quantity'),
location,
user=user,
notes=notes,
discard_allocations=discard_allocations,
)
@tracer.start_as_current_span('complete_build_outputs')
@@ -194,17 +223,34 @@ def complete_build_outputs(
with transaction.atomic():
for item in outputs:
output = StockItem.objects.filter(pk=item['output_id']).first()
if output:
build.complete_build_output(
output,
user,
quantity=item.get('quantity'),
location=location,
status=status,
notes=notes,
required_tests=required_tests,
# Lock the output row, and re-check that it is still "in production" -
# it may have been processed already (e.g. by a duplicated task)
output = (
StockItem.objects
.select_for_update()
.filter(pk=item['output_id'])
.first()
)
if not output:
continue
if not output.is_building:
logger.warning(
'Build output <%s> is no longer in production - skipping completion',
output.pk,
)
continue
build.complete_build_output(
output,
user,
quantity=item.get('quantity'),
location=location,
status=status,
notes=notes,
required_tests=required_tests,
)
@tracer.start_as_current_span('cancel_build')
+1 -3
View File
@@ -837,9 +837,7 @@ class BuildAllocationTest(BuildAPITest):
)
# Test a fractional quantity when the *available* quantity is less than 1
si = StockItem.objects.create(
part=si.part, quantity=0.3159, tree_id=0, level=0, lft=0, rght=0
)
si = StockItem.objects.create(part=si.part, quantity=0.3159)
self.post(
self.url,
+190 -4
View File
@@ -26,7 +26,12 @@ from InvenTree.unit_test import (
)
from order.models import PurchaseOrder, PurchaseOrderLineItem
from part.models import BomItem, BomItemSubstitute, Part, PartTestTemplate
from stock.models import StockItem, StockItemTestResult, StockLocation
from stock.models import (
StockItem,
StockItemTestResult,
StockItemTracking,
StockLocation,
)
from stock.status_codes import StockStatus
from users.models import Owner
@@ -527,15 +532,14 @@ class BuildTest(BuildTestBase):
# Return a partial quantity of each item to stock
for item in consumed_items:
self.assertEqual(item.get_descendant_count(), 0)
q = item.quantity
self.assertGreater(item.quantity, 1)
item.return_to_stock(location, merge=False, quantity=1)
item.refresh_from_db()
self.assertEqual(item.quantity, q - 1)
self.assertEqual(item.get_descendant_count(), 1)
self.assertEqual(item.children.count(), 1)
self.assertFalse(item.is_in_stock())
child = item.get_descendants().first()
child = item.children.first()
self.assertTrue(child.is_in_stock())
def test_change_part(self):
@@ -643,6 +647,122 @@ class BuildTest(BuildTestBase):
for output in outputs:
self.assertFalse(output.is_building)
def test_complete_output_stale_build_instance(self):
"""The 'completed' count is incremented atomically at the database level.
Simulates two concurrent processes completing different outputs of the
same build, each holding its own (stale) copy of the Build instance.
"""
self.stock_1_1.quantity = 1000
self.stock_1_1.save()
self.stock_2_1.quantity = 30
self.stock_2_1.save()
self.build.issue_build()
# Allocate non-tracked parts
self.allocate_stock(
None,
{
self.stock_1_1: self.stock_1_1.quantity,
self.stock_1_2: 10,
self.stock_2_1: 30,
},
)
# Allocate tracked parts against each output
self.allocate_stock(self.output_1, {self.stock_3_1: 6})
self.allocate_stock(self.output_2, {self.stock_3_1: 14})
# Two independent in-memory copies of the same build
build_a = Build.objects.get(pk=self.build.pk)
build_b = Build.objects.get(pk=self.build.pk)
build_a.complete_build_output(self.output_1, None)
build_b.complete_build_output(self.output_2, None)
# Both completions must be counted
self.build.refresh_from_db()
self.assertEqual(self.build.completed, 10)
def test_complete_allocation_stale_build_line(self):
"""The 'consumed' count is incremented atomically at the database level.
Simulates two concurrent workers completing different allocations
against the same BuildLine, each holding its own (stale) copy of the line.
"""
self.build.issue_build()
self.allocate_stock(None, {self.stock_1_1: 3, self.stock_1_2: 5})
alloc_a, alloc_b = BuildItem.objects.filter(build_line=self.line_1).order_by(
'pk'
)
# Cache a separate copy of the BuildLine on each allocation
self.assertEqual(alloc_a.build_line.consumed, 0)
self.assertEqual(alloc_b.build_line.consumed, 0)
alloc_a.complete_allocation(user=self.user)
alloc_b.complete_allocation(user=self.user)
# Both consumed quantities must be counted
self.line_1.refresh_from_db()
self.assertEqual(self.line_1.consumed, 8)
def test_complete_zero_quantity_allocation(self):
"""A zero-quantity allocation is removed cleanly on completion.
Regression test: completing a BuildItem with quantity=0 (permitted by
the model validators) crashed with an AttributeError, blocking build
completion until the empty allocation was manually removed.
"""
self.build.issue_build()
# An allocation with zero quantity, against an item with stock available
alloc = BuildItem.objects.create(
build_line=self.line_1, stock_item=self.stock_1_2, quantity=0
)
n_items = StockItem.objects.count()
alloc.complete_allocation(user=self.user)
# The empty allocation is deleted, with no stock operations performed
self.assertFalse(BuildItem.objects.filter(pk=alloc.pk).exists())
self.assertEqual(StockItem.objects.count(), n_items)
self.stock_1_2.refresh_from_db()
self.assertEqual(self.stock_1_2.quantity, 100)
self.assertIsNone(self.stock_1_2.consumed_by)
self.line_1.refresh_from_db()
self.assertEqual(self.line_1.consumed, 0)
# An allocation whose stock item has been depleted elsewhere
# is also removed cleanly (allocated quantity clamps to zero)
depleted = StockItem.objects.create(
part=self.sub_part_1, quantity=5, delete_on_deplete=False
)
alloc = BuildItem.objects.create(
build_line=self.line_1, stock_item=depleted, quantity=5
)
depleted.take_stock(5, self.user)
depleted.refresh_from_db()
self.assertEqual(depleted.quantity, 0)
alloc.complete_allocation(user=self.user)
self.assertFalse(BuildItem.objects.filter(pk=alloc.pk).exists())
depleted.refresh_from_db()
self.assertIsNone(depleted.consumed_by)
self.line_1.refresh_from_db()
self.assertEqual(self.line_1.consumed, 0)
def test_complete_with_required_tests(self):
"""Test the prevention completion when a required test is missing feature."""
# with required tests incompleted the save should fail
@@ -1263,6 +1383,72 @@ class BuildTaskTests(BuildTestBase):
self.build.complete_build_output(self.output_1, None)
self.build.complete_build_output(self.output_2, None)
# -----------------------------------------------------------------------
# complete_build_outputs / scrap_build_outputs tasks
# -----------------------------------------------------------------------
def test_complete_outputs_task_is_idempotent(self):
"""Duplicate execution of the output completion task must not double-count.
Regression test: the task never re-checked 'is_building', so a duplicated
(or redelivered) task run completed the same outputs twice - inflating the
'completed' count for the build order and duplicating stock history.
"""
self.build.issue_build()
outputs = [{'output_id': self.output_1.pk}, {'output_id': self.output_2.pk}]
build.tasks.complete_build_outputs(
self.build.pk,
outputs,
self.location.pk,
StockStatus.OK.value,
user_id=self.user.pk,
)
self.build.refresh_from_db()
self.output_1.refresh_from_db()
self.assertEqual(self.build.completed, 10)
self.assertFalse(self.output_1.is_building)
n_tracking = StockItemTracking.objects.count()
# Run the task again (simulating a duplicated / redelivered task)
build.tasks.complete_build_outputs(
self.build.pk,
outputs,
self.location.pk,
StockStatus.OK.value,
user_id=self.user.pk,
)
# The 'completed' count has not been double-counted,
# and no additional stock history has been generated
self.build.refresh_from_db()
self.assertEqual(self.build.completed, 10)
self.assertEqual(StockItemTracking.objects.count(), n_tracking)
def test_complete_output_twice_rejected(self):
"""Completing or scrapping an already-completed output must be rejected.
Regression test: neither complete_build_output() nor scrap_build_output()
re-checked the 'is_building' state of the output.
"""
self.build.issue_build()
self.build.complete_build_output(self.output_1, None)
with self.assertRaises(ValidationError):
self.build.complete_build_output(self.output_1, None)
with self.assertRaises(ValidationError):
self.build.scrap_build_output(self.output_1, None, self.location)
# An output belonging to a *different* build order is also rejected
with self.assertRaises(ValidationError):
self.build.complete_build_output(self.stockitem_wo_required_test, None)
# -----------------------------------------------------------------------
# cancel_build task
# -----------------------------------------------------------------------
+1 -1
View File
@@ -10,7 +10,7 @@ import common.validators
class ParameterTemplateAdmin(admin.ModelAdmin):
"""Admin interface for ParameterTemplate objects."""
list_display = ('name', 'description', 'model_type', 'units')
list_display = ('name', 'description', 'model_type', 'units', 'unique')
search_fields = ('name', 'description')
+1 -1
View File
@@ -978,7 +978,7 @@ class ParameterTemplateFilter(FilterSet):
"""Metaclass options."""
model = common.models.ParameterTemplate
fields = ['name', 'units', 'checkbox', 'enabled']
fields = ['name', 'units', 'checkbox', 'enabled', 'unique']
has_choices = rest_filters.BooleanFilter(
method='filter_has_choices', label='Has Choice'
+12 -4
View File
@@ -162,13 +162,21 @@ def currency_exchange_plugins() -> Optional[list]:
def get_price(
instance,
quantity,
moq=True,
multiples=True,
currency=None,
moq: bool = True,
multiples: bool = True,
currency: Optional[str] = None,
break_name: str = 'price_breaks',
):
"""Calculate the price based on quantity price breaks.
Arguments:
instance: The model instance which contains the price break information
quantity: The quantity to calculate the price for
moq: If True, then minimum order quantity will be observed (CURRENTLY NOT IMPLEMENTED)
multiples: If True, then order multiples will be observed
currency: The currency code to use for the calculation (default is None)
break_name: The name of the price break field on the instance (default is 'price_breaks')
- Don't forget to add in flat-fee cost (base_cost field)
- If MOQ (minimum order quantity) is required, bump quantity
- If order multiples are to be observed, then we need to calculate based on that, too
@@ -185,7 +193,7 @@ def get_price(
return None
# Check if quantity is fraction and disable multiples
multiples = quantity % 1 == 0
multiples = multiples and (quantity % 1 == 0)
# Order multiples
if multiples:
@@ -0,0 +1,23 @@
# Generated by Django 5.2.15 on 2026-07-14 00:05
from django.db import migrations, models
from common.models import ParameterTemplate
class Migration(migrations.Migration):
dependencies = [
("common", "0046_alter_emailmessage_global_id_and_more"),
]
operations = [
migrations.AddField(
model_name="parametertemplate",
name="unique",
field=models.PositiveIntegerField(
choices=ParameterTemplate.UniqueOptions.choices,
default=0,
help_text="Enforce uniqueness of linked parameter values against this template",
verbose_name="Uniqueness",
),
),
]
+71 -4
View File
@@ -1391,21 +1391,27 @@ class PriceBreak(MetaMixin):
help_text=_('Unit price at specified quantity'),
)
def convert_to(self, currency_code):
def convert_to(self, currency_code: str, raise_error: bool = False):
"""Convert the unit-price at this price break to the specified currency code.
Args:
Arguments:
currency_code: The currency code to convert to (e.g "USD" or "AUD")
raise_error: If True, raise an error if the conversion fails. If False, return None.
"""
try:
converted = convert_money(self.price, currency_code)
except MissingRate:
except MissingRate: # pragma: no cover
InvenTree.exceptions.log_error('PriceBreak.convert_to')
logger.warning(
'No currency conversion rate available for %s -> %s',
self.price_currency,
currency_code,
)
return self.price.amount
if raise_error:
raise
return None
return converted.amount
@@ -2601,6 +2607,19 @@ class ParameterTemplate(
choice_fnc = common.validators.parameter_template_model_options
class UniqueOptions(models.IntegerChoices):
"""Enumeration of uniqueness options for a ParameterTemplate.
Attributes:
NONE: No uniqueness requirement is enforced (default)
MODEL_TYPE: Linked parameter values must be unique for a given model type
GLOBAL: Linked parameter values must be unique across all model types
"""
NONE = 0, _('No uniqueness required')
MODEL_TYPE = 1, _('Unique for model type')
GLOBAL = 2, _('Globally unique')
@staticmethod
def get_api_url() -> str:
"""Return the API URL associated with the ParameterTemplate model."""
@@ -2744,6 +2763,15 @@ class ParameterTemplate(
help_text=_('Is this parameter template enabled?'),
)
unique = models.PositiveIntegerField(
default=UniqueOptions.NONE,
choices=UniqueOptions.choices,
verbose_name=_('Uniqueness'),
help_text=_(
'Enforce uniqueness of linked parameter values against this template'
),
)
@receiver(
post_save, sender=ParameterTemplate, dispatch_uid='post_save_parameter_template'
@@ -2849,6 +2877,9 @@ class Parameter(
except ValidationError as e:
raise ValidationError({'data': e.message})
# Validate the parameter data against any uniqueness requirements imposed by the template
self.validate_uniqueness()
if InvenTree.ready.isReadOnlyCommand():
# Skip plugin validation checks during read-only management commands
return
@@ -2896,6 +2927,42 @@ class Parameter(
if math.isnan(self.data_numeric) or math.isinf(self.data_numeric):
self.data_numeric = None
def validate_uniqueness(self):
"""Ensure that this Parameter satisfies any uniqueness requirements imposed by its template.
The ParameterTemplate.unique field determines the scope of the uniqueness check:
- NONE: No uniqueness check is performed
- MODEL_TYPE: The value must be unique amongst other parameters (for this template) linked to the same model type
- GLOBAL: The value must be unique amongst all other parameters linked to this template
Note: If the template defines a set of 'units', the comparison is performed against the
normalized 'data_numeric' value, so that equivalent values expressed in different
(but compatible) units are correctly detected as duplicates (e.g. '1k' and '1000' ohms).
"""
uniqueness = self.template.unique
if uniqueness == ParameterTemplate.UniqueOptions.NONE:
return
if self.template.units and self.data_numeric is not None:
query = Parameter.objects.filter(
template=self.template, data_numeric=self.data_numeric
)
else:
query = Parameter.objects.filter(
template=self.template, data__iexact=self.data
)
if self.pk:
query = query.exclude(pk=self.pk)
if uniqueness == ParameterTemplate.UniqueOptions.MODEL_TYPE:
query = query.filter(model_type=self.model_type)
if query.exists():
raise ValidationError({'data': _('Parameter value must be unique')})
def check_permission(self, permission, user):
"""Check if the user has the required permission for this parameter."""
from InvenTree.models import InvenTreeParameterMixin
@@ -979,6 +979,7 @@ class ParameterTemplateSerializer(
'choices',
'selectionlist',
'enabled',
'unique',
]
# Note: The choices are overridden at run-time on class initialization
@@ -223,6 +223,12 @@ USER_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
('MMM DD YYYY', 'Feb 22 2022'),
],
},
'ENABLE_PREVIEW_PANEL': {
'name': _('Table Preview Panel'),
'description': _('Display a preview panel when selecting items in tables'),
'default': False,
'validator': bool,
},
'DISPLAY_STOCKTAKE_TAB': {
'name': _('Show Stock History'),
'description': _('Display stock history information in the part detail page'),
+175
View File
@@ -2,6 +2,7 @@
import io
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.core.files.uploadedfile import SimpleUploadedFile
@@ -73,6 +74,7 @@ class ParameterAPITests(InvenTreeAPITestCase):
'model_type',
'selectionlist',
'enabled',
'unique',
]:
self.assertIn(
field,
@@ -567,6 +569,179 @@ class ParameterAPITests(InvenTreeAPITestCase):
common.models.Parameter.objects.filter(pk=parameter.pk).exists()
)
def test_parameter_uniqueness(self):
"""Test the uniqueness options which can be applied to a ParameterTemplate."""
from company.models import Company
from part.models import Part
part_a = Part.objects.create(name='Part A', description='A part for testing')
part_b = Part.objects.create(name='Part B', description='A part for testing')
part_c = Part.objects.create(name='Part C', description='A part for testing')
company = Company.objects.create(
name='Test Company', description='A company for testing'
)
template = common.models.ParameterTemplate.objects.create(
name='Serial Number', description='A serial number parameter'
)
self.assertEqual(
template.unique, common.models.ParameterTemplate.UniqueOptions.NONE
)
param_a = common.models.Parameter(
template=template,
model_type=part_a.get_content_type(),
model_id=part_a.pk,
data='ABC123',
)
param_a.full_clean()
param_a.save()
# No uniqueness requirement - a duplicate value against a different part is fine
param_b = common.models.Parameter(
template=template,
model_type=part_b.get_content_type(),
model_id=part_b.pk,
data='ABC123',
)
param_b.full_clean()
param_b.save()
# Re-saving the existing instance (unchanged) should not raise any errors
param_a.full_clean()
param_a.save()
# Now, require uniqueness *per model type*
template.unique = common.models.ParameterTemplate.UniqueOptions.MODEL_TYPE
template.save()
# A new Part with the same value should be rejected
with self.assertRaises(ValidationError):
common.models.Parameter(
template=template,
model_type=part_c.get_content_type(),
model_id=part_c.pk,
data='ABC123',
).full_clean()
# A case-insensitive match should also be rejected
with self.assertRaises(ValidationError):
common.models.Parameter(
template=template,
model_type=part_c.get_content_type(),
model_id=part_c.pk,
data='abc123',
).full_clean()
# A different model type entirely is not affected by the 'model type' restriction
param_company = common.models.Parameter(
template=template,
model_type=company.get_content_type(),
model_id=company.pk,
data='ABC123',
)
param_company.full_clean()
param_company.save()
# Finally, require the value to be *globally* unique
template.unique = common.models.ParameterTemplate.UniqueOptions.GLOBAL
template.save()
with self.assertRaises(ValidationError):
common.models.Parameter(
template=template,
model_type=part_c.get_content_type(),
model_id=part_c.pk,
data='ABC123',
).full_clean()
def test_parameter_uniqueness_units(self):
"""Test that uniqueness checks are unit-aware for templates which define units.
Values expressed in different (but compatible) units which represent the
same physical quantity must be detected as duplicates.
"""
from part.models import Part
part_a = Part.objects.create(name='Part A', description='A part for testing')
part_b = Part.objects.create(name='Part B', description='A part for testing')
template = common.models.ParameterTemplate.objects.create(
name='Resistance',
units='ohm',
description='A globally unique resistance parameter',
unique=common.models.ParameterTemplate.UniqueOptions.GLOBAL,
)
param_a = common.models.Parameter(
template=template,
model_type=part_a.get_content_type(),
model_id=part_a.pk,
data='1000',
)
param_a.full_clean()
param_a.save()
# A value expressed as '1k' ohms is numerically identical to '1000' ohms
with self.assertRaises(ValidationError):
common.models.Parameter(
template=template,
model_type=part_b.get_content_type(),
model_id=part_b.pk,
data='1k',
).full_clean()
# A distinct value (in different units) is not a duplicate
param_b = common.models.Parameter(
template=template,
model_type=part_b.get_content_type(),
model_id=part_b.pk,
data='2k',
)
param_b.full_clean()
param_b.save()
def test_copy_unique_parameters(self):
"""Test that 'unique' parameters are skipped when copying parameters between model instances."""
from part.models import Part
part_a = Part.objects.create(name='Part A', description='A part for testing')
part_b = Part.objects.create(name='Part B', description='A part for testing')
normal_template = common.models.ParameterTemplate.objects.create(
name='Color', description='A normal (non-unique) parameter'
)
unique_template = common.models.ParameterTemplate.objects.create(
name='Serial Number',
description='A globally unique parameter',
unique=common.models.ParameterTemplate.UniqueOptions.GLOBAL,
)
common.models.Parameter.objects.create(
template=normal_template,
model_type=part_a.get_content_type(),
model_id=part_a.pk,
data='Red',
)
common.models.Parameter.objects.create(
template=unique_template,
model_type=part_a.get_content_type(),
model_id=part_a.pk,
data='ABC123',
)
# Copy parameters from part_a to part_b
part_b.copy_parameters_from(part_a)
# The non-unique parameter should have been copied
self.assertEqual(part_b.get_parameter('Color').data, 'Red')
# The unique parameter should *not* have been copied, to avoid a conflicting value
self.assertIsNone(part_b.get_parameter('Serial Number'))
def test_parameter_annotation(self):
"""Test that we can annotate parameters against a queryset."""
from company.models import Company
-19
View File
@@ -1,7 +1,6 @@
"""Unit tests for the models in the 'company' app."""
import os
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.test import TestCase
@@ -99,24 +98,6 @@ class CompanySimpleTest(TestCase):
self.assertEqual(p(45), 315)
self.assertEqual(p(55), 68.75)
def test_part_pricing(self):
"""Unit tests for supplier part pricing."""
m2x4 = Part.objects.get(name='M2x4 LPHS')
self.assertEqual(m2x4.get_price_info(5.5), '38.5 - 41.25')
self.assertEqual(m2x4.get_price_info(10), '70 - 75')
self.assertEqual(m2x4.get_price_info(100), '125 - 350')
pmin, pmax = m2x4.get_price_range(5)
self.assertEqual(pmin, 35)
self.assertEqual(pmax, 37.5)
m3x12 = Part.objects.get(name='M3x12 SHCS')
self.assertEqual(m3x12.get_price_info(0.3), Decimal('2.4'))
self.assertEqual(m3x12.get_price_info(3), Decimal('24'))
self.assertIsNotNone(m3x12.get_price_info(50))
def test_currency_validation(self):
"""Test validation for currency selection."""
# Create a company with a valid currency code (should pass)
+1 -1
View File
@@ -341,7 +341,7 @@ class DataImportSession(models.Model):
logger.error('Failed to load data file')
return
headers = df.headers
headers = importer.operations.normalize_headers(df.headers)
imported_rows = []
+22 -6
View File
@@ -72,17 +72,33 @@ def extract_column_names(data_file) -> list:
"""
data = load_data_file(data_file)
headers = []
return normalize_headers(data.headers)
for idx, header in enumerate(data.headers):
def normalize_headers(headers) -> list:
"""Normalize a list of raw column headers extracted from a data file.
Strips whitespace from each header, and generates a default header
for any column that does not have one. Must be used consistently
wherever column headers are extracted, so that column names used for
field mapping match the column names used when extracting row data.
Args:
headers: List of raw header values (as returned by tablib)
Returns:
List of normalized column names
"""
result = []
for idx, header in enumerate(headers):
if header:
header = str(header).strip()
headers.append(header)
result.append(str(header).strip())
else:
# If the header is empty, generate a default header
headers.append(f'Column {idx + 1}')
result.append(f'Column {idx + 1}')
return headers
return result
def get_field_label(field) -> Optional[str]:
+74
View File
@@ -77,6 +77,80 @@ class ImporterTest(ImporterMixin, InvenTreeTestCase):
# Check that the new companies have been created
self.assertEqual(n + 12, Company.objects.count())
def test_import_header_whitespace(self):
"""Test that column headers with leading/trailing whitespace are handled correctly.
Regression test: column mappings are built from *stripped* header names, but
row data was previously extracted using the *raw* (unstripped) headers. If a
header had surrounding whitespace, the mapped column name would never match a
key in the row data, so that field was silently skipped during import.
"""
from company.models import Company
n = Company.objects.count()
# Pad each header in the source file with extra whitespace
raw = self.helper_file('companies.csv').read()
lines = raw.splitlines()
headers = [f' {header} ' for header in lines[0].split(',')]
padded_data = '\n'.join([','.join(headers), *lines[1:]])
data_file = ContentFile(padded_data, 'companies_whitespace.csv')
session = DataImportSession.objects.create(
data_file=data_file, model_type='company'
)
session.extract_columns()
# Extracted column names should have whitespace stripped
for col in session.columns:
self.assertEqual(col, col.strip())
# Field mappings should be created correctly, against the *stripped* names
for field, col in [
('website', 'Website'),
('is_customer', 'Is customer'),
('phone', 'Phone number'),
('description', 'Company description'),
('active', 'Active'),
]:
self.assertTrue(
session.column_mappings.filter(field=field, column=col).exists()
)
# Run the data import
session.import_data()
self.assertEqual(session.rows.count(), 12)
rows = list(session.rows.all())
# Row data keys must be stripped, to match the mapped column names
for row in rows:
for key in row.row_data:
self.assertEqual(key, key.strip())
# Find the 'Arrow' row, and check that mapped fields were actually extracted
arrow_row = next(row for row in rows if row.data.get('name') == 'Arrow')
self.assertEqual(arrow_row.data.get('website'), 'https://www.arrow.com/')
self.assertEqual(arrow_row.data.get('description'), 'Arrow Electronics')
self.assertEqual(arrow_row.data.get('is_customer'), False)
for row in rows:
self.assertIsNotNone(row.data.get('name', None))
self.assertTrue(row.valid)
row.validate(commit=True)
self.assertTrue(row.complete)
# All rows accepted: rows and mappings are cleared, session is retained
session.refresh_from_db()
self.assertEqual(session.rows.count(), 0)
self.assertEqual(session.column_mappings.count(), 0)
# Check that the new companies have been created
self.assertEqual(n + 12, Company.objects.count())
def test_field_defaults(self):
"""Test default field values."""
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

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