mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 03:26:45 +00:00
[Pricing] Add option to convert received items currency (#8970)
* Add new global setting * Convert to base currency on receipt * Fix total price rendering in PO table * Fix for tasks.py * Update .gitignore - Ignore auto-generated files * Update docs Improved documentation for pricing/currency support * Updates * Fix caching for default currency - Now managed better by session caching * Add unit test for new feature * Playwright test fixes * Validate copying of media files * Validate media files * Adjust playwright setup * Allow multiple attempts to fetch release information * Tweak unit tests * Revert changes to .gitignore file - Just trying stuff at this point * Add debug msg * Try hard-coded paths * Remove debug prints * Abs path for database * More debug * Fix typos * Revert change to db name * Remove debug statements (again) * Cleanup playwright tests * More test tweaks --------- Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
parent
a760d00c96
commit
d363c408f8
9
.github/workflows/qc_checks.yaml
vendored
9
.github/workflows/qc_checks.yaml
vendored
@ -17,10 +17,11 @@ env:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
INVENTREE_DB_ENGINE: sqlite3
|
INVENTREE_DB_ENGINE: sqlite3
|
||||||
INVENTREE_DB_NAME: inventree
|
INVENTREE_DB_NAME: inventree
|
||||||
INVENTREE_MEDIA_ROOT: ../test_inventree_media
|
INVENTREE_MEDIA_ROOT: /home/runner/work/InvenTree/test_inventree_media
|
||||||
INVENTREE_STATIC_ROOT: ../test_inventree_static
|
INVENTREE_STATIC_ROOT: /home/runner/work/InvenTree/test_inventree_static
|
||||||
INVENTREE_BACKUP_DIR: ../test_inventree_backup
|
INVENTREE_BACKUP_DIR: /home/runner/work/InvenTree/test_inventree_backup
|
||||||
INVENTREE_SITE_URL: http://localhost:8000
|
INVENTREE_SITE_URL: http://localhost:8000
|
||||||
|
INVENTREE_DEBUG: true
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@ -570,7 +571,7 @@ jobs:
|
|||||||
install: true
|
install: true
|
||||||
update: true
|
update: true
|
||||||
- name: Set up test data
|
- name: Set up test data
|
||||||
run: invoke dev.setup-test -i
|
run: invoke dev.setup-test -iv
|
||||||
- name: Rebuild thumbnails
|
- name: Rebuild thumbnails
|
||||||
run: invoke int.rebuild-thumbnails
|
run: invoke int.rebuild-thumbnails
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -112,7 +112,7 @@ api.yaml
|
|||||||
src/backend/InvenTree/web/static
|
src/backend/InvenTree/web/static
|
||||||
InvenTree/web/static
|
InvenTree/web/static
|
||||||
|
|
||||||
# Generated docs files
|
# Exported interim files
|
||||||
docs/schema.yml
|
docs/schema.yml
|
||||||
docs/docs/api/*.yml
|
docs/docs/api/*.yml
|
||||||
docs/docs/api/schema/*.yml
|
docs/docs/api/schema/*.yml
|
||||||
|
65
docs/docs/concepts/pricing.md
Normal file
65
docs/docs/concepts/pricing.md
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
title: Pricing Support
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pricing
|
||||||
|
|
||||||
|
Pricing is an inherently complex topic, often subject to the particular requirements of the user. InvenTree attempts to provide a comprehensive pricing architecture which is useful without being proscriptive.
|
||||||
|
|
||||||
|
InvenTree provides support for multiple currencies, allowing pricing information to be stored with base currency rates.
|
||||||
|
|
||||||
|
!!! warning "Raw Data Only"
|
||||||
|
InvenTree stores raw pricing data, as provided by the user. Any calculations or decisions based on this data must take into consideration the context in which the data are entered.
|
||||||
|
|
||||||
|
InvenTree uses the [django-money](https://github.com/django-money/django-money) library, which in turn uses the [py-moneyed library](https://py-moneyed.readthedocs.io/en/latest/index.html). `py-moneyed` supports any currency which is defined in the [ISO 3166 standard](https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes) standard.
|
||||||
|
|
||||||
|
|
||||||
|
### Terminology
|
||||||
|
|
||||||
|
Throughout this documentation (and within InvenTree) the concepts of *cost* and *price* are separated as follows:
|
||||||
|
|
||||||
|
| Term | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| Price | The theoretical amount of money required to pay for something. |
|
||||||
|
| Cost | The actual amount of money paid. |
|
||||||
|
|
||||||
|
|
||||||
|
## Currency Support
|
||||||
|
|
||||||
|
InvenTree supports pricing data in multiple currencies, allowing integration with suppliers and customers using different currency systems.
|
||||||
|
|
||||||
|
### Default Currency
|
||||||
|
|
||||||
|
Many of the pricing operations are performed in reference to a *Default Currency* (which can be selected for the particular InvenTree installation).
|
||||||
|
|
||||||
|
The default currency is user configurable in the InvenTree settings.
|
||||||
|
|
||||||
|
!!! warning "Setting Default Currency"
|
||||||
|
Changing the default currency once the system in use may have unintended consequences. It is recommended to set the default currency during the initial setup of the InvenTree instance.
|
||||||
|
|
||||||
|
## Conversion Rates
|
||||||
|
|
||||||
|
To facilitate conversion between different currencies, exchange rate information is stored in the InvenTree database.
|
||||||
|
|
||||||
|
### Currency Codes
|
||||||
|
|
||||||
|
The list of support currency codes is user configurable in the InvenTree settings. It is recommended to select only the currencies which are relevant to the user.
|
||||||
|
|
||||||
|
While InvenTree can support any of the currencies defined in the ISO 3166 standard, the list of supported currencies can be limited to only those which are relevant to the user. The supported currencies are used to populate the currency selection dropdowns throughout the InvenTree interface.
|
||||||
|
|
||||||
|
|
||||||
|
### Exchange Rate Data
|
||||||
|
|
||||||
|
The exchange rate data is provided by a [currency plugin](../extend/plugins/currency.md) which fetches exchange rate data from an external source.
|
||||||
|
|
||||||
|
InvenTree includes a default currency plugin which fetches exchange rate data from the [frankfurter](https://frankfurter.dev/) API, which is an open source currency API made freely available.
|
||||||
|
|
||||||
|
However, the user can configure a custom currency plugin to fetch exchange rate data from a different source. If a different currency exchange backend is needed, or a custom implementation is desired, the currency exchange framework can be extended [via plugins](../extend/plugins/currency.md). Plugins which implement custom currency exchange frameworks can be easily integrated into the InvenTree framework.
|
||||||
|
|
||||||
|
### Exchange Rate Updates
|
||||||
|
|
||||||
|
Currency exchange rates are updated periodically, using the configured currency plugin. The update frequency can be configured in the InvenTree settings.
|
||||||
|
|
||||||
|
## Pricing Settings
|
||||||
|
|
||||||
|
Refer to the [global settings](../settings/global.md#pricing-and-currency) documentation for more information on available currency settings.
|
@ -113,8 +113,18 @@ def get_release_data():
|
|||||||
while 1:
|
while 1:
|
||||||
url = f'https://api.github.com/repos/inventree/inventree/releases?page={page}&per_page=150'
|
url = f'https://api.github.com/repos/inventree/inventree/releases?page={page}&per_page=150'
|
||||||
|
|
||||||
response = requests.get(url, timeout=30)
|
attempts = 5
|
||||||
assert response.status_code == 200
|
|
||||||
|
while attempts > 0:
|
||||||
|
attempts -= 1
|
||||||
|
|
||||||
|
response = requests.get(url, timeout=30)
|
||||||
|
if response.status_code == 200:
|
||||||
|
break
|
||||||
|
|
||||||
|
assert response.status_code == 200, (
|
||||||
|
f'Failed to fetch release data: {response.status_code} - {url}'
|
||||||
|
)
|
||||||
|
|
||||||
data = json.loads(response.text)
|
data = json.loads(response.text)
|
||||||
|
|
||||||
|
@ -42,7 +42,14 @@ Purchase Order Status supports [custom states](../concepts/custom_states.md).
|
|||||||
|
|
||||||
### Purchase Order Currency
|
### Purchase Order Currency
|
||||||
|
|
||||||
The currency code can be specified for an individual purchase order. If not specified, the default currency specified against the [supplier](./company.md#suppliers) will be used.
|
The currency code can be specified for an individual purchase order. If not specified, the default currency specified against the [supplier](./company.md#suppliers) will be used. Additionally, the currency can be specified separately for each line item.
|
||||||
|
|
||||||
|
So, when determining the cost of each line item in the purchase order, the following order of precedence is used:
|
||||||
|
|
||||||
|
1. Line item currency
|
||||||
|
2. Purchase order currency
|
||||||
|
3. Supplier currency
|
||||||
|
4. Default (base) currency
|
||||||
|
|
||||||
## Create Purchase Order
|
## Create Purchase Order
|
||||||
|
|
||||||
@ -77,11 +84,11 @@ It is possible to upload an exported purchase order from the supplier instead of
|
|||||||
!!! info "Supported Formats"
|
!!! info "Supported Formats"
|
||||||
This process only supports tabular data and the following formats are supported: CSV, TSV, XLS, XLSX, JSON and YAML
|
This process only supports tabular data and the following formats are supported: CSV, TSV, XLS, XLSX, JSON and YAML
|
||||||
|
|
||||||
### Issue Order
|
## Issue Order
|
||||||
|
|
||||||
Once all the line items were added, click on the <span class='fas fa-paper-plane'></span> button on the main purchase order detail panel and confirm the order has been submitted.
|
Once all the line items were added, click on the <span class='fas fa-paper-plane'></span> button on the main purchase order detail panel and confirm the order has been submitted.
|
||||||
|
|
||||||
### Receive Line Items
|
## Receive Line Items
|
||||||
|
|
||||||
After receiving all the items from the order, the purchase order will convert the line items into stock items / inventory.
|
After receiving all the items from the order, the purchase order will convert the line items into stock items / inventory.
|
||||||
|
|
||||||
@ -107,6 +114,12 @@ Each item marked as "received" is automatically converted into a stock item.
|
|||||||
|
|
||||||
To see the list of stock items created from the purchase order, click on the <span class="badge inventree nav side"><span class='fas fa-sign-in-alt'></span> Received Items</span> tab.
|
To see the list of stock items created from the purchase order, click on the <span class="badge inventree nav side"><span class='fas fa-sign-in-alt'></span> Received Items</span> tab.
|
||||||
|
|
||||||
|
### Item Value Currency
|
||||||
|
|
||||||
|
The unit cost of the purchase order line item is transferred across to the created stock item. By default, the same currency is used for the stock item as was used for the purchase order line item.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Complete Order
|
## 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__.
|
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__.
|
||||||
@ -174,5 +187,6 @@ The following [global settings](../settings/global.md) are available for purchas
|
|||||||
| ---- | ----------- | ------- | ----- |
|
| ---- | ----------- | ------- | ----- |
|
||||||
{{ globalsetting("PURCHASEORDER_REFERENCE_PATTERN") }}
|
{{ globalsetting("PURCHASEORDER_REFERENCE_PATTERN") }}
|
||||||
{{ globalsetting("PURCHASEORDER_REQUIRE_RESPONSIBLE") }}
|
{{ globalsetting("PURCHASEORDER_REQUIRE_RESPONSIBLE") }}
|
||||||
|
{{ globalsetting("PURCHASEORDER_CONVERT_CURRENCY") }}
|
||||||
{{ globalsetting("PURCHASEORDER_EDIT_COMPLETED_ORDERS") }}
|
{{ globalsetting("PURCHASEORDER_EDIT_COMPLETED_ORDERS") }}
|
||||||
{{ globalsetting("PURCHASEORDER_AUTO_COMPLETE") }}
|
{{ globalsetting("PURCHASEORDER_AUTO_COMPLETE") }}
|
||||||
|
@ -2,23 +2,10 @@
|
|||||||
title: Pricing
|
title: Pricing
|
||||||
---
|
---
|
||||||
|
|
||||||
## Pricing
|
## Part Pricing
|
||||||
|
|
||||||
Pricing is an inherently complex topic, often subject to the particular requirements of the user. InvenTree attempts to provide a comprehensive pricing architecture which is useful without being proscriptive.
|
!!! info "Pricing Support"
|
||||||
|
Refer to the [Pricing Support](../concepts/pricing.md) documentation for more information on pricing support in InvenTree.
|
||||||
!!! warning "Raw Data Only"
|
|
||||||
InvenTree stores raw pricing data, as provided by the user. Any calculations or decisions based on this data must take into consideration the context in which the data are entered.
|
|
||||||
|
|
||||||
### Terminology
|
|
||||||
|
|
||||||
Throughout this documentation (and within InvenTree) the concepts of *cost* and *price* are separated as follows:
|
|
||||||
|
|
||||||
| Term | Description |
|
|
||||||
| --- | --- |
|
|
||||||
| Price | The theoretical amount of money required to pay for something. |
|
|
||||||
| Cost | The actual amount of money paid. |
|
|
||||||
|
|
||||||
### Pricing Sources
|
|
||||||
|
|
||||||
Pricing information can be determined from multiple sources:
|
Pricing information can be determined from multiple sources:
|
||||||
|
|
||||||
@ -42,26 +29,6 @@ Additionally, the following information is stored for each part, in relation to
|
|||||||
| Sale Price | How much a salable item is sold for (with price-breaks) | [Part](../part/part.md) |
|
| Sale Price | How much a salable item is sold for (with price-breaks) | [Part](../part/part.md) |
|
||||||
| Sale Cost | How much an item was sold for | [Sales Order](../order/sales_order.md) |
|
| Sale Cost | How much an item was sold for | [Sales Order](../order/sales_order.md) |
|
||||||
|
|
||||||
### Currency Support
|
|
||||||
|
|
||||||
InvenTree supports pricing data in multiple currencies, allowing integration with suppliers and customers using different currency systems.
|
|
||||||
|
|
||||||
Supported currencies can be configured in the [InvenTree settings](../settings/currency.md).
|
|
||||||
|
|
||||||
!!! info "Currency Support"
|
|
||||||
InvenTree provides multi-currency pricing support via the [django-money](https://django-money.readthedocs.io/en/latest/) library.
|
|
||||||
|
|
||||||
#### Default Currency
|
|
||||||
|
|
||||||
Many of the pricing operations are performed in reference to a *Default Currency* (which can be selected for the particular InvenTree installation).
|
|
||||||
|
|
||||||
#### Conversion Rates
|
|
||||||
|
|
||||||
To facilitate conversion between different currencies, exchange rate data is provided via the [exchangerate.host](https://exchangerate.host/#/) API. Currency exchange rates are updated once per day.
|
|
||||||
|
|
||||||
!!! tip "Custom Exchange Rates"
|
|
||||||
Custom exchange rates or databases can be used if desired.
|
|
||||||
|
|
||||||
## Pricing Tab
|
## Pricing Tab
|
||||||
|
|
||||||
The pricing tab for a given Part provides all available pricing information for that part. It shows all price ranges and provides tools to calculate them.
|
The pricing tab for a given Part provides all available pricing information for that part. It shows all price ranges and provides tools to calculate them.
|
||||||
@ -81,7 +48,7 @@ At the top of the pricing tab, an *Overview* section shows a synopsis of the ava
|
|||||||
|
|
||||||
This overview tab provides information on the *range* of pricing data available within each category. If pricing data is not available for a given category, it is marked as *No data*.
|
This overview tab provides information on the *range* of pricing data available within each category. If pricing data is not available for a given category, it is marked as *No data*.
|
||||||
|
|
||||||
Each price range is calculated in the [Default Currency](#default-currency), independent of the currency in which the original pricing information is stored. This is necessary for operations such as data sorting, price comparison, etc. Note that while the *overview* information is calculated in a single currency, the original pricing information is still available in the original currency.
|
Each price range is calculated in the [Default Currency](../concepts/pricing.md#default-currency), independent of the currency in which the original pricing information is stored. This is necessary for operations such as data sorting, price comparison, etc. Note that while the *overview* information is calculated in a single currency, the original pricing information is still available in the original currency.
|
||||||
|
|
||||||
Price range data is [cached in the database](#price-data-caching) when underlying pricing information changes.
|
Price range data is [cached in the database](#price-data-caching) when underlying pricing information changes.
|
||||||
|
|
||||||
@ -167,7 +134,7 @@ Pricing calculations (and conversions) can be expensive to perform. This can mak
|
|||||||
|
|
||||||
For this reason, all information displayed in the [pricing overview](#pricing-overview) section is pre-calculated and *cached* in the database. This ensures that when it needs to be retrieved (e.g. viewing pricing for an entire BOM) it can be accessed immediately.
|
For this reason, all information displayed in the [pricing overview](#pricing-overview) section is pre-calculated and *cached* in the database. This ensures that when it needs to be retrieved (e.g. viewing pricing for an entire BOM) it can be accessed immediately.
|
||||||
|
|
||||||
Pricing data is cached in the [default currency](#default-currency), which ensures that pricing can be compared across multiple parts in a consistent format.
|
Pricing data is cached in the [default currency](../concepts/pricing.md/#default-currency), which ensures that pricing can be compared across multiple parts in a consistent format.
|
||||||
|
|
||||||
#### Data Updates
|
#### Data Updates
|
||||||
|
|
||||||
|
@ -1,31 +0,0 @@
|
|||||||
---
|
|
||||||
title: Currency Support
|
|
||||||
---
|
|
||||||
|
|
||||||
## Currency Support
|
|
||||||
|
|
||||||
InvenTree provides support for multiple currencies, allowing pricing information to be stored with base currency rates.
|
|
||||||
|
|
||||||
### Supported Currencies
|
|
||||||
|
|
||||||
InvenTree uses the [django-money](https://github.com/django-money/django-money) library, which in turn uses the [py-moneyed library](https://py-moneyed.readthedocs.io/en/latest/index.html). `py-moneyed` supports any currency which is defined in the [ISO 3166 standard](https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes) standard.
|
|
||||||
|
|
||||||
### Currency Conversion
|
|
||||||
|
|
||||||
Currency conversion is provided via the `django-money` library. Pricing data can be converted seamlessly between the available currencies.
|
|
||||||
|
|
||||||
### Currency Rate Updates
|
|
||||||
|
|
||||||
Currency conversion rates are periodically updated, via an external currency exchange server. Out of the box, InvenTree uses the [frankfurter.app](https://www.frankfurter.app/) service, which is an open source currency API made freely available.
|
|
||||||
|
|
||||||
#### Custom Rate Updates
|
|
||||||
|
|
||||||
If a different currency exchange backend is needed, or a custom implementation is desired, the currency exchange framework can be extended [via plugins](../extend/plugins/currency.md). Plugins which implement custom currency exchange frameworks can be easily integrated into the InvenTree framework.
|
|
||||||
|
|
||||||
### Currency Settings
|
|
||||||
|
|
||||||
Refer to the [global settings](./global.md#pricing-and-currency) documentation for more information on available currency settings.
|
|
||||||
|
|
||||||
#### Supported Currencies
|
|
||||||
|
|
||||||
While InvenTree can support any of the currencies defined in the ISO 3166 standard, the list of supported currencies can be limited to only those which are relevant to the user. The supported currencies are used to populate the currency selection dropdowns throughout the InvenTree interface.
|
|
@ -101,11 +101,18 @@ Configuration of pricing data and currency support:
|
|||||||
| ---- | ----------- | ------- | ----- |
|
| ---- | ----------- | ------- | ----- |
|
||||||
{{ globalsetting("INVENTREE_DEFAULT_CURRENCY") }}
|
{{ globalsetting("INVENTREE_DEFAULT_CURRENCY") }}
|
||||||
{{ globalsetting("CURRENCY_CODES") }}
|
{{ globalsetting("CURRENCY_CODES") }}
|
||||||
{{ globalsetting("PART_INTERNAL_PRICE") }}
|
|
||||||
{{ globalsetting("PART_BOM_USE_INTERNAL_PRICE") }}
|
|
||||||
{{ globalsetting("PRICING_DECIMAL_PLACES_MIN") }}
|
{{ globalsetting("PRICING_DECIMAL_PLACES_MIN") }}
|
||||||
{{ globalsetting("PRICING_DECIMAL_PLACES") }}
|
{{ globalsetting("PRICING_DECIMAL_PLACES") }}
|
||||||
{{ globalsetting("PRICING_UPDATE_DAYS") }}
|
{{ globalsetting("PRICING_UPDATE_DAYS") }}
|
||||||
|
|
||||||
|
#### Part Pricing
|
||||||
|
|
||||||
|
Configuration of part pricing:
|
||||||
|
|
||||||
|
| Name | Description | Default | Units |
|
||||||
|
| ---- | ----------- | ------- | ----- |
|
||||||
|
{{ globalsetting("PART_INTERNAL_PRICE") }}
|
||||||
|
{{ globalsetting("PART_BOM_USE_INTERNAL_PRICE") }}
|
||||||
{{ globalsetting("PRICING_USE_SUPPLIER_PRICING") }}
|
{{ globalsetting("PRICING_USE_SUPPLIER_PRICING") }}
|
||||||
{{ globalsetting("PRICING_PURCHASE_HISTORY_OVERRIDES_SUPPLIER") }}
|
{{ globalsetting("PRICING_PURCHASE_HISTORY_OVERRIDES_SUPPLIER") }}
|
||||||
{{ globalsetting("PRICING_USE_STOCK_PRICING") }}
|
{{ globalsetting("PRICING_USE_STOCK_PRICING") }}
|
||||||
|
@ -78,6 +78,7 @@ nav:
|
|||||||
- Terminology: concepts/terminology.md
|
- Terminology: concepts/terminology.md
|
||||||
- Physical Units: concepts/units.md
|
- Physical Units: concepts/units.md
|
||||||
- Custom States: concepts/custom_states.md
|
- Custom States: concepts/custom_states.md
|
||||||
|
- Pricing: concepts/pricing.md
|
||||||
- Development:
|
- Development:
|
||||||
- Contributing: develop/contributing.md
|
- Contributing: develop/contributing.md
|
||||||
- Devcontainer: develop/devcontainer.md
|
- Devcontainer: develop/devcontainer.md
|
||||||
@ -157,7 +158,6 @@ nav:
|
|||||||
- Single Sign on: settings/SSO.md
|
- Single Sign on: settings/SSO.md
|
||||||
- Multi Factor Authentication: settings/MFA.md
|
- Multi Factor Authentication: settings/MFA.md
|
||||||
- Email: settings/email.md
|
- Email: settings/email.md
|
||||||
- Currency Support: settings/currency.md
|
|
||||||
- Export Data: settings/export.md
|
- Export Data: settings/export.md
|
||||||
- Import Data: settings/import.md
|
- Import Data: settings/import.md
|
||||||
- Operations:
|
- Operations:
|
||||||
|
@ -150,7 +150,7 @@ def do_typecast(value, type, var_name=None):
|
|||||||
var_name: Name that should be logged e.g. 'INVENTREE_STATIC_ROOT'. Set if logging is required.
|
var_name: Name that should be logged e.g. 'INVENTREE_STATIC_ROOT'. Set if logging is required.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Typecasted value or original value if typecasting failed.
|
Typecast value or original value if typecasting failed.
|
||||||
"""
|
"""
|
||||||
# Force 'list' of strings
|
# Force 'list' of strings
|
||||||
if type is list:
|
if type is list:
|
||||||
|
@ -9,7 +9,6 @@ from django.conf import settings
|
|||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import rest_framework.views as drfviews
|
|
||||||
import structlog
|
import structlog
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.exceptions import ValidationError as DRFValidationError
|
from rest_framework.exceptions import ValidationError as DRFValidationError
|
||||||
@ -77,6 +76,8 @@ def exception_handler(exc, context):
|
|||||||
|
|
||||||
If sentry error reporting is enabled, we will also provide the original exception to sentry.io
|
If sentry error reporting is enabled, we will also provide the original exception to sentry.io
|
||||||
"""
|
"""
|
||||||
|
import rest_framework.views as drfviews
|
||||||
|
|
||||||
import InvenTree.sentry
|
import InvenTree.sentry
|
||||||
|
|
||||||
response = None
|
response = None
|
||||||
|
@ -33,7 +33,7 @@ class InvenTreeRestURLField(RestURLField):
|
|||||||
self.validators[-1].schemes = allowable_url_schemes()
|
self.validators[-1].schemes = allowable_url_schemes()
|
||||||
|
|
||||||
def run_validation(self, data=empty):
|
def run_validation(self, data=empty):
|
||||||
"""Override default validation behaviour for this field type."""
|
"""Override default validation behavior for this field type."""
|
||||||
strict_urls = get_global_setting('INVENTREE_STRICT_URLS', cache=False)
|
strict_urls = get_global_setting('INVENTREE_STRICT_URLS', cache=False)
|
||||||
|
|
||||||
if not strict_urls and data is not empty and '://' not in data:
|
if not strict_urls and data is not empty and '://' not in data:
|
||||||
|
@ -92,7 +92,7 @@ class InvenTreeOrderingFilter(filters.OrderingFilter):
|
|||||||
|
|
||||||
Then, specify a ordering_field_aliases attribute:
|
Then, specify a ordering_field_aliases attribute:
|
||||||
|
|
||||||
ordering_field_alises = {
|
ordering_field_aliases = {
|
||||||
'name': 'part__part__name',
|
'name': 'part__part__name',
|
||||||
'SKU': 'part__SKU',
|
'SKU': 'part__SKU',
|
||||||
}
|
}
|
||||||
|
@ -157,7 +157,7 @@ class CustomAllauthTwoFactorMiddleware(AllauthTwoFactorMiddleware):
|
|||||||
"""This function ensures only frontend code triggers the MFA auth cycle."""
|
"""This function ensures only frontend code triggers the MFA auth cycle."""
|
||||||
|
|
||||||
def process_request(self, request):
|
def process_request(self, request):
|
||||||
"""Check if requested url is forntend and enforce MFA check."""
|
"""Check if requested url is frontend and enforce MFA check."""
|
||||||
try:
|
try:
|
||||||
if not url_matcher.resolve(request.path[1:]):
|
if not url_matcher.resolve(request.path[1:]):
|
||||||
super().process_request(request)
|
super().process_request(request)
|
||||||
|
@ -96,7 +96,7 @@ class CleanMixin:
|
|||||||
def clean_data(self, data: dict) -> dict:
|
def clean_data(self, data: dict) -> dict:
|
||||||
"""Clean / sanitize data.
|
"""Clean / sanitize data.
|
||||||
|
|
||||||
This uses mozillas bleach under the hood to disable certain html tags by
|
This uses Mozilla's bleach under the hood to disable certain html tags by
|
||||||
encoding them - this leads to script tags etc. to not work.
|
encoding them - this leads to script tags etc. to not work.
|
||||||
The results can be longer then the input; might make some character combinations
|
The results can be longer then the input; might make some character combinations
|
||||||
`ugly`. Prevents XSS on the server-level.
|
`ugly`. Prevents XSS on the server-level.
|
||||||
|
@ -16,7 +16,7 @@ def get_model_for_view(view):
|
|||||||
return view.serializer_class.Meta.model
|
return view.serializer_class.Meta.model
|
||||||
|
|
||||||
if hasattr(view, 'get_serializer_class'):
|
if hasattr(view, 'get_serializer_class'):
|
||||||
return view.get_serializr_class().Meta.model
|
return view.get_serializer_class().Meta.model
|
||||||
|
|
||||||
raise AttributeError(f'Serializer class not specified for {view.__class__}')
|
raise AttributeError(f'Serializer class not specified for {view.__class__}')
|
||||||
|
|
||||||
|
@ -4,7 +4,6 @@ import decimal
|
|||||||
import math
|
import math
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from django.core.cache import cache
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
@ -20,14 +19,6 @@ def currency_code_default():
|
|||||||
"""Returns the default currency code (or USD if not specified)."""
|
"""Returns the default currency code (or USD if not specified)."""
|
||||||
from common.settings import get_global_setting
|
from common.settings import get_global_setting
|
||||||
|
|
||||||
try:
|
|
||||||
cached_value = cache.get('currency_code_default', '')
|
|
||||||
except Exception:
|
|
||||||
cached_value = None
|
|
||||||
|
|
||||||
if cached_value:
|
|
||||||
return cached_value
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
code = get_global_setting('INVENTREE_DEFAULT_CURRENCY', create=True, cache=True)
|
code = get_global_setting('INVENTREE_DEFAULT_CURRENCY', create=True, cache=True)
|
||||||
except Exception: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
@ -37,12 +28,6 @@ def currency_code_default():
|
|||||||
if code not in CURRENCIES:
|
if code not in CURRENCIES:
|
||||||
code = 'USD' # pragma: no cover
|
code = 'USD' # pragma: no cover
|
||||||
|
|
||||||
# Cache the value for a short amount of time
|
|
||||||
try:
|
|
||||||
cache.set('currency_code_default', code, 30)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return code
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
@ -834,6 +834,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
|
|||||||
'default': False,
|
'default': False,
|
||||||
'validator': bool,
|
'validator': bool,
|
||||||
},
|
},
|
||||||
|
'PURCHASEORDER_CONVERT_CURRENCY': {
|
||||||
|
'name': _('Convert Currency'),
|
||||||
|
'description': _('Convert item value to base currency when receiving stock'),
|
||||||
|
'default': False,
|
||||||
|
'validator': bool,
|
||||||
|
},
|
||||||
'PURCHASEORDER_AUTO_COMPLETE': {
|
'PURCHASEORDER_AUTO_COMPLETE': {
|
||||||
'name': _('Auto Complete Purchase Orders'),
|
'name': _('Auto Complete Purchase Orders'),
|
||||||
'description': _(
|
'description': _(
|
||||||
|
@ -881,7 +881,19 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
|||||||
# Calculate unit purchase price (in base units)
|
# Calculate unit purchase price (in base units)
|
||||||
if line.purchase_price:
|
if line.purchase_price:
|
||||||
unit_purchase_price = line.purchase_price
|
unit_purchase_price = line.purchase_price
|
||||||
|
|
||||||
|
# Convert purchase price to base units
|
||||||
unit_purchase_price /= line.part.base_quantity(1)
|
unit_purchase_price /= line.part.base_quantity(1)
|
||||||
|
|
||||||
|
# Convert to base currency
|
||||||
|
if get_global_setting('PURCHASEORDER_CONVERT_CURRENCY'):
|
||||||
|
try:
|
||||||
|
unit_purchase_price = convert_money(
|
||||||
|
unit_purchase_price, currency_code_default()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
log_error('PurchaseOrder.receive_line_item')
|
||||||
|
|
||||||
else:
|
else:
|
||||||
unit_purchase_price = None
|
unit_purchase_price = None
|
||||||
|
|
||||||
|
@ -12,7 +12,9 @@ from djmoney.money import Money
|
|||||||
|
|
||||||
import common.models
|
import common.models
|
||||||
import order.tasks
|
import order.tasks
|
||||||
|
from common.settings import get_global_setting, set_global_setting
|
||||||
from company.models import Company, SupplierPart
|
from company.models import Company, SupplierPart
|
||||||
|
from InvenTree.unit_test import ExchangeRateMixin
|
||||||
from order.status_codes import PurchaseOrderStatus
|
from order.status_codes import PurchaseOrderStatus
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
from stock.models import StockItem, StockLocation
|
from stock.models import StockItem, StockLocation
|
||||||
@ -21,7 +23,7 @@ from users.models import Owner
|
|||||||
from .models import PurchaseOrder, PurchaseOrderExtraLine, PurchaseOrderLineItem
|
from .models import PurchaseOrder, PurchaseOrderExtraLine, PurchaseOrderLineItem
|
||||||
|
|
||||||
|
|
||||||
class OrderTest(TestCase):
|
class OrderTest(TestCase, ExchangeRateMixin):
|
||||||
"""Tests to ensure that the order models are functioning correctly."""
|
"""Tests to ensure that the order models are functioning correctly."""
|
||||||
|
|
||||||
fixtures = [
|
fixtures = [
|
||||||
@ -305,6 +307,44 @@ class OrderTest(TestCase):
|
|||||||
self.assertEqual(si.quantity, 0.5)
|
self.assertEqual(si.quantity, 0.5)
|
||||||
self.assertEqual(si.purchase_price, Money(100, 'USD'))
|
self.assertEqual(si.purchase_price, Money(100, 'USD'))
|
||||||
|
|
||||||
|
def test_receive_convert_currency(self):
|
||||||
|
"""Test receiving orders with different currencies."""
|
||||||
|
# Setup some dummy exchange rates
|
||||||
|
self.generate_exchange_rates()
|
||||||
|
|
||||||
|
set_global_setting('INVENTREE_DEFAULT_CURRENCY', 'USD')
|
||||||
|
self.assertEqual(get_global_setting('INVENTREE_DEFAULT_CURRENCY'), 'USD')
|
||||||
|
|
||||||
|
# Enable auto conversion
|
||||||
|
set_global_setting('PURCHASEORDER_CONVERT_CURRENCY', True)
|
||||||
|
|
||||||
|
order = PurchaseOrder.objects.get(pk=7)
|
||||||
|
sku = SupplierPart.objects.get(SKU='ZERGM312')
|
||||||
|
loc = StockLocation.objects.get(id=1)
|
||||||
|
|
||||||
|
# Add a line item (in CAD)
|
||||||
|
line = order.add_line_item(sku, 100, purchase_price=Money(1.25, 'CAD'))
|
||||||
|
|
||||||
|
order.place_order()
|
||||||
|
|
||||||
|
# Receive a line item, should be converted to GBP
|
||||||
|
order.receive_line_item(line, loc, 50, user=None)
|
||||||
|
item = order.stock_items.order_by('-pk').first()
|
||||||
|
|
||||||
|
self.assertEqual(item.quantity, 50)
|
||||||
|
self.assertEqual(item.purchase_price_currency, 'USD')
|
||||||
|
self.assertAlmostEqual(item.purchase_price.amount, Decimal(0.7353), 3)
|
||||||
|
|
||||||
|
# Disable auto conversion
|
||||||
|
set_global_setting('PURCHASEORDER_CONVERT_CURRENCY', False)
|
||||||
|
|
||||||
|
order.receive_line_item(line, loc, 30, user=None)
|
||||||
|
item = order.stock_items.order_by('-pk').first()
|
||||||
|
|
||||||
|
self.assertEqual(item.quantity, 30)
|
||||||
|
self.assertEqual(item.purchase_price_currency, 'CAD')
|
||||||
|
self.assertAlmostEqual(item.purchase_price.amount, Decimal(1.25), 3)
|
||||||
|
|
||||||
def test_overdue_notification(self):
|
def test_overdue_notification(self):
|
||||||
"""Test overdue purchase order notification.
|
"""Test overdue purchase order notification.
|
||||||
|
|
||||||
|
@ -12,6 +12,7 @@ from django.db.utils import IntegrityError, OperationalError, ProgrammingError
|
|||||||
|
|
||||||
from maintenance_mode.core import maintenance_mode_on, set_maintenance_mode
|
from maintenance_mode.core import maintenance_mode_on, set_maintenance_mode
|
||||||
|
|
||||||
|
import InvenTree.exceptions
|
||||||
import InvenTree.ready
|
import InvenTree.ready
|
||||||
|
|
||||||
logger = logging.getLogger('inventree')
|
logger = logging.getLogger('inventree')
|
||||||
@ -141,7 +142,7 @@ class ReportConfig(AppConfig):
|
|||||||
)
|
)
|
||||||
logger.info("Creating new label template: '%s'", template['name'])
|
logger.info("Creating new label template: '%s'", template['name'])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
InvenTree.exceptions.log_error('create_default_labels')
|
||||||
|
|
||||||
def create_default_reports(self):
|
def create_default_reports(self):
|
||||||
"""Create default report templates."""
|
"""Create default report templates."""
|
||||||
@ -232,4 +233,4 @@ class ReportConfig(AppConfig):
|
|||||||
)
|
)
|
||||||
logger.info("Created new report template: '%s'", template['name'])
|
logger.info("Created new report template: '%s'", template['name'])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
InvenTree.exceptions.log_error('create_default_reports')
|
||||||
|
@ -252,6 +252,7 @@ export default function SystemSettings() {
|
|||||||
keys={[
|
keys={[
|
||||||
'PURCHASEORDER_REFERENCE_PATTERN',
|
'PURCHASEORDER_REFERENCE_PATTERN',
|
||||||
'PURCHASEORDER_REQUIRE_RESPONSIBLE',
|
'PURCHASEORDER_REQUIRE_RESPONSIBLE',
|
||||||
|
'PURCHASEORDER_CONVERT_CURRENCY',
|
||||||
'PURCHASEORDER_EDIT_COMPLETED_ORDERS',
|
'PURCHASEORDER_EDIT_COMPLETED_ORDERS',
|
||||||
'PURCHASEORDER_AUTO_COMPLETE'
|
'PURCHASEORDER_AUTO_COMPLETE'
|
||||||
]}
|
]}
|
||||||
|
@ -32,8 +32,7 @@ import {
|
|||||||
NoteColumn,
|
NoteColumn,
|
||||||
PartColumn,
|
PartColumn,
|
||||||
ReferenceColumn,
|
ReferenceColumn,
|
||||||
TargetDateColumn,
|
TargetDateColumn
|
||||||
TotalPriceColumn
|
|
||||||
} from '../ColumnRenderers';
|
} from '../ColumnRenderers';
|
||||||
import type { TableFilter } from '../Filter';
|
import type { TableFilter } from '../Filter';
|
||||||
import { InvenTreeTable } from '../InvenTreeTable';
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
@ -226,7 +225,11 @@ export function PurchaseOrderLineItemTable({
|
|||||||
accessor: 'purchase_price',
|
accessor: 'purchase_price',
|
||||||
title: t`Unit Price`
|
title: t`Unit Price`
|
||||||
}),
|
}),
|
||||||
TotalPriceColumn(),
|
CurrencyColumn({
|
||||||
|
accessor: 'total_price',
|
||||||
|
currency_accessor: 'purchase_price_currency',
|
||||||
|
title: t`Total Price`
|
||||||
|
}),
|
||||||
TargetDateColumn({}),
|
TargetDateColumn({}),
|
||||||
{
|
{
|
||||||
accessor: 'destination',
|
accessor: 'destination',
|
||||||
|
@ -10,10 +10,11 @@ import { doQuickLogin } from '../login.ts';
|
|||||||
test('Build Order - Basic Tests', async ({ page }) => {
|
test('Build Order - Basic Tests', async ({ page }) => {
|
||||||
await doQuickLogin(page);
|
await doQuickLogin(page);
|
||||||
|
|
||||||
await navigate(page, 'part/');
|
|
||||||
|
|
||||||
// Navigate to the correct build order
|
// Navigate to the correct build order
|
||||||
await page.getByRole('tab', { name: 'Manufacturing', exact: true }).click();
|
await page.getByRole('tab', { name: 'Manufacturing', exact: true }).click();
|
||||||
|
await page.getByRole('tab', { name: 'Build Orders', exact: true }).click();
|
||||||
|
|
||||||
|
await clearTableFilters(page);
|
||||||
|
|
||||||
// We have now loaded the "Build Order" table. Check for some expected texts
|
// We have now loaded the "Build Order" table. Check for some expected texts
|
||||||
await page.getByText('On Hold').first().waitFor();
|
await page.getByText('On Hold').first().waitFor();
|
||||||
@ -120,6 +121,8 @@ test('Build Order - Build Outputs', async ({ page }) => {
|
|||||||
await navigate(page, 'manufacturing/index/');
|
await navigate(page, 'manufacturing/index/');
|
||||||
await page.getByRole('tab', { name: 'Build Orders', exact: true }).click();
|
await page.getByRole('tab', { name: 'Build Orders', exact: true }).click();
|
||||||
|
|
||||||
|
await clearTableFilters(page);
|
||||||
|
|
||||||
// We have now loaded the "Build Order" table. Check for some expected texts
|
// We have now loaded the "Build Order" table. Check for some expected texts
|
||||||
await page.getByText('On Hold').first().waitFor();
|
await page.getByText('On Hold').first().waitFor();
|
||||||
await page.getByText('Pending').first().waitFor();
|
await page.getByText('Pending').first().waitFor();
|
||||||
|
@ -122,6 +122,7 @@ test('Parts - Allocations', async ({ page }) => {
|
|||||||
|
|
||||||
// Navigate to the "Allocations" tab
|
// Navigate to the "Allocations" tab
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
await page.getByRole('tab', { name: 'Allocations' }).click();
|
await page.getByRole('tab', { name: 'Allocations' }).click();
|
||||||
|
|
||||||
|
@ -63,13 +63,9 @@ test('Sales Orders - Tabs', async ({ page }) => {
|
|||||||
test('Sales Orders - Basic Tests', async ({ page }) => {
|
test('Sales Orders - Basic Tests', async ({ page }) => {
|
||||||
await doQuickLogin(page);
|
await doQuickLogin(page);
|
||||||
|
|
||||||
await navigate(page, 'home');
|
|
||||||
await page.getByRole('tab', { name: 'Sales' }).click();
|
await page.getByRole('tab', { name: 'Sales' }).click();
|
||||||
await page.getByRole('tab', { name: 'Sales Orders' }).click();
|
await page.getByRole('tab', { name: 'Sales Orders' }).click();
|
||||||
|
|
||||||
// Check for expected text in the table
|
|
||||||
await page.getByRole('tab', { name: 'Sales Orders' }).waitFor();
|
|
||||||
|
|
||||||
await clearTableFilters(page);
|
await clearTableFilters(page);
|
||||||
|
|
||||||
await setTableChoiceFilter(page, 'status', 'On Hold');
|
await setTableChoiceFilter(page, 'status', 'On Hold');
|
||||||
@ -106,7 +102,6 @@ test('Sales Orders - Basic Tests', async ({ page }) => {
|
|||||||
test('Sales Orders - Shipments', async ({ page }) => {
|
test('Sales Orders - Shipments', async ({ page }) => {
|
||||||
await doQuickLogin(page);
|
await doQuickLogin(page);
|
||||||
|
|
||||||
await navigate(page, 'home');
|
|
||||||
await page.getByRole('tab', { name: 'Sales' }).click();
|
await page.getByRole('tab', { name: 'Sales' }).click();
|
||||||
await page.getByRole('tab', { name: 'Sales Orders' }).click();
|
await page.getByRole('tab', { name: 'Sales Orders' }).click();
|
||||||
|
|
||||||
|
@ -60,6 +60,8 @@ test('Report Printing', async ({ page }) => {
|
|||||||
|
|
||||||
// Navigate to a specific PurchaseOrder
|
// Navigate to a specific PurchaseOrder
|
||||||
await page.getByRole('tab', { name: 'Purchasing' }).click();
|
await page.getByRole('tab', { name: 'Purchasing' }).click();
|
||||||
|
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
|
||||||
|
|
||||||
await page.getByRole('cell', { name: 'PO0009' }).click();
|
await page.getByRole('cell', { name: 'PO0009' }).click();
|
||||||
|
|
||||||
// Select "print report"
|
// Select "print report"
|
||||||
|
@ -95,10 +95,14 @@ test('Settings - Admin', async ({ page }) => {
|
|||||||
await page.getByLabel('row-action-menu-0').click();
|
await page.getByLabel('row-action-menu-0').click();
|
||||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||||
await expect(page.getByLabel('text-field-name')).toHaveValue('Room');
|
await expect(page.getByLabel('text-field-name')).toHaveValue('Room');
|
||||||
await expect(page.getByLabel('text-field-description')).toHaveValue('A room');
|
|
||||||
await page.getByLabel('text-field-name').fill('Large Room');
|
// Toggle the "description" field
|
||||||
await page.waitForTimeout(500);
|
const oldDescription = await page
|
||||||
await page.getByLabel('text-field-description').fill('A large room');
|
.getByLabel('text-field-description')
|
||||||
|
.inputValue();
|
||||||
|
const newDescription = `${oldDescription} (edited)`;
|
||||||
|
|
||||||
|
await page.getByLabel('text-field-description').fill(newDescription);
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await page.getByRole('button', { name: 'Submit' }).click();
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
|
||||||
@ -114,13 +118,9 @@ test('Settings - Admin', async ({ page }) => {
|
|||||||
// Edit first item again (revert values)
|
// Edit first item again (revert values)
|
||||||
await page.getByLabel('row-action-menu-0').click();
|
await page.getByLabel('row-action-menu-0').click();
|
||||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||||
await expect(page.getByLabel('text-field-name')).toHaveValue('Large Room');
|
|
||||||
await expect(page.getByLabel('text-field-description')).toHaveValue(
|
|
||||||
'A large room'
|
|
||||||
);
|
|
||||||
await page.getByLabel('text-field-name').fill('Room');
|
await page.getByLabel('text-field-name').fill('Room');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await page.getByLabel('text-field-description').fill('A room');
|
await page.getByLabel('text-field-description').fill(oldDescription);
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
await page.getByRole('button', { name: 'Submit' }).click();
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
});
|
});
|
||||||
|
49
tasks.py
49
tasks.py
@ -428,7 +428,9 @@ def rebuild_models(c):
|
|||||||
@task
|
@task
|
||||||
def rebuild_thumbnails(c):
|
def rebuild_thumbnails(c):
|
||||||
"""Rebuild missing image thumbnails."""
|
"""Rebuild missing image thumbnails."""
|
||||||
info('Rebuilding image thumbnails')
|
from src.backend.InvenTree.InvenTree.config import get_media_dir
|
||||||
|
|
||||||
|
info(f'Rebuilding image thumbnails in {get_media_dir()}')
|
||||||
manage(c, 'rebuild_thumbnails', pty=True)
|
manage(c, 'rebuild_thumbnails', pty=True)
|
||||||
|
|
||||||
|
|
||||||
@ -1125,8 +1127,19 @@ def test(
|
|||||||
manage(c, cmd, pty=pty)
|
manage(c, cmd, pty=pty)
|
||||||
|
|
||||||
|
|
||||||
@task(help={'dev': 'Set up development environment at the end'})
|
@task(
|
||||||
def setup_test(c, ignore_update=False, dev=False, path='inventree-demo-dataset'):
|
help={
|
||||||
|
'dev': 'Set up development environment at the end',
|
||||||
|
'validate_files': 'Validate media files are correctly copied',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def setup_test(
|
||||||
|
c,
|
||||||
|
ignore_update=False,
|
||||||
|
dev=False,
|
||||||
|
validate_files=False,
|
||||||
|
path='inventree-demo-dataset',
|
||||||
|
):
|
||||||
"""Setup a testing environment."""
|
"""Setup a testing environment."""
|
||||||
from src.backend.InvenTree.InvenTree.config import get_media_dir
|
from src.backend.InvenTree.InvenTree.config import get_media_dir
|
||||||
|
|
||||||
@ -1156,13 +1169,35 @@ def setup_test(c, ignore_update=False, dev=False, path='inventree-demo-dataset')
|
|||||||
import_records(c, filename=template_dir.joinpath('inventree_data.json'), clear=True)
|
import_records(c, filename=template_dir.joinpath('inventree_data.json'), clear=True)
|
||||||
|
|
||||||
# Copy media files
|
# Copy media files
|
||||||
info('Copying media files ...')
|
|
||||||
src = template_dir.joinpath('media')
|
src = template_dir.joinpath('media')
|
||||||
dst = get_media_dir()
|
dst = get_media_dir()
|
||||||
|
|
||||||
info(f'Copying media files - "{src}" to "{dst}"')
|
info(f'Copying media files - "{src}" to "{dst}"')
|
||||||
shutil.copytree(src, dst, dirs_exist_ok=True)
|
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||||
|
|
||||||
|
if validate_files:
|
||||||
|
info(' - Validating media files')
|
||||||
|
missing = False
|
||||||
|
# Check that the media files are correctly copied across
|
||||||
|
for dirpath, _dirnames, filenames in os.walk(src):
|
||||||
|
rel_path = os.path.relpath(dirpath, src)
|
||||||
|
dst_path = os.path.join(dst, rel_path)
|
||||||
|
|
||||||
|
if not os.path.exists(dst_path):
|
||||||
|
error(f' - Missing directory: {dst_path}')
|
||||||
|
missing = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
for filename in filenames:
|
||||||
|
dst_file = os.path.join(dst_path, filename)
|
||||||
|
if not os.path.exists(dst_file):
|
||||||
|
missing = True
|
||||||
|
error(f' - Missing file: {dst_file}')
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
raise FileNotFoundError('Media files not correctly copied')
|
||||||
|
else:
|
||||||
|
success(' - All media files correctly copied')
|
||||||
|
|
||||||
info('Done setting up test environment...')
|
info('Done setting up test environment...')
|
||||||
|
|
||||||
# Set up development setup if flag is set
|
# Set up development setup if flag is set
|
||||||
@ -1587,9 +1622,7 @@ via your signed in browser, or consider using a point release download via invok
|
|||||||
def docs_server(c, address='localhost:8080', compile_schema=False):
|
def docs_server(c, address='localhost:8080', compile_schema=False):
|
||||||
"""Start a local mkdocs server to view the documentation."""
|
"""Start a local mkdocs server to view the documentation."""
|
||||||
# Extract settings definitions
|
# Extract settings definitions
|
||||||
export_settings_definitions(
|
export_definitions(c, basedir='docs')
|
||||||
c, filename='docs/inventree_settings.json', overwrite=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if compile_schema:
|
if compile_schema:
|
||||||
# Build the schema docs first
|
# Build the schema docs first
|
||||||
|
Loading…
x
Reference in New Issue
Block a user