mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-28 01:17:21 +00:00
[docs] Report discovery (#12449)
* Adds decorator to mark an attribute for report context discovery * Update context variables in docs * Include fields captured against each model type * Expose more properties * Image URL tags * Add more attributes * Rearrange docs * Simplify docs * Enforce type hinting for reportable properties * Expose more properties for the Order model * Auto-discover related / linked models * Remove extra text * Collapsible blocks * Reduce boilerplate * Remove cruft * Enhance documentation for filename generation * Cleanup
This commit is contained in:
@@ -31,7 +31,7 @@ The barcode is tested as follows, in decreasing order of priority:
|
||||
|
||||
InvenTree comes with a few builtin supplier plugins, which handle their respective barcode formats.
|
||||
|
||||
Scanning a supplier barcode for a supplied part will link to the corresponding supplier part if the [SKU](../report/context_variables.md#supplierpart) from the barcode could be matched.
|
||||
Scanning a supplier barcode for a supplied part will link to the corresponding supplier part if the [SKU](../report/model_context.md#supplier-part) from the barcode could be matched.
|
||||
|
||||
The following suppliers (and barcode formats) are currently supported:
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
title: Context Variables
|
||||
title: Report Context
|
||||
---
|
||||
|
||||
|
||||
## Context Variables
|
||||
## Report Context Variables
|
||||
|
||||
Context variables are provided to each template when it is rendered. The available context variables depend on the model type for which the template is being rendered.
|
||||
|
||||
@@ -13,9 +13,6 @@ In addition to the model-specific context variables, the following global contex
|
||||
|
||||
{{ report_context("base", "global") }}
|
||||
|
||||
::: report.models.ReportTemplateBase.base_context
|
||||
options:
|
||||
show_source: True
|
||||
|
||||
### Report Context
|
||||
|
||||
@@ -23,7 +20,7 @@ In addition to the [global context](#global-context), all *report* templates hav
|
||||
|
||||
{{ report_context("base", "report") }}
|
||||
|
||||
When using the `merge` context variable, the selected items are available in the `instances` list. {{ templatefile("report/inventree_stock_report_merge.html") }} shows a complete example. To access individual item attributes, you can either loop through the `instances` or access them by index like `instance.0.name`.
|
||||
When using the `merge` context variable, the selected items are available in the `instances` list. To access individual item attributes, you can either loop through the `instances` or access them by index like `instance.0.name`.
|
||||
|
||||
Below is an example template that generates a single report for some selected parts. Each part occupies a row in the table
|
||||
|
||||
@@ -87,9 +84,8 @@ When printing a report or label against a Company instance, the following contex
|
||||
|
||||
{{ report_context("models", "company") }}
|
||||
|
||||
::: company.models.Company.report_context
|
||||
options:
|
||||
show_source: True
|
||||
For the fields and properties available on the underlying `Company` instance, refer to the [Model Context](model_context.md#company) page.
|
||||
|
||||
|
||||
### Build Order
|
||||
|
||||
@@ -97,9 +93,8 @@ When printing a report or label against a [Build Order](../manufacturing/build.m
|
||||
|
||||
{{ report_context("models", "build") }}
|
||||
|
||||
::: build.models.Build.report_context
|
||||
options:
|
||||
show_source: True
|
||||
For the fields and properties available on the underlying `Build` instance, refer to the [Model Context](model_context.md#build-order) page.
|
||||
|
||||
|
||||
### Build Line
|
||||
|
||||
@@ -107,9 +102,7 @@ When printing a report or label against a [BuildOrderLineItem](../manufacturing/
|
||||
|
||||
{{ report_context("models", "buildline") }}
|
||||
|
||||
::: build.models.BuildLine.report_context
|
||||
options:
|
||||
show_source: True
|
||||
For the fields and properties available on the underlying `BuildLine` instance, refer to the [Model Context](model_context.md#build-order-line-item) page.
|
||||
|
||||
### Sales Order
|
||||
|
||||
@@ -117,9 +110,7 @@ When printing a report or label against a [SalesOrder](../sales/sales_order.md)
|
||||
|
||||
{{ report_context("models", "salesorder") }}
|
||||
|
||||
::: order.models.Order.report_context
|
||||
options:
|
||||
show_source: True
|
||||
For the fields and properties available on the underlying `SalesOrder` instance, refer to the [Model Context](model_context.md#sales-order) page.
|
||||
|
||||
### Sales Order Shipment
|
||||
|
||||
@@ -127,9 +118,7 @@ When printing a report or label against a [SalesOrderShipment](../sales/sales_or
|
||||
|
||||
{{ report_context("models", "salesordershipment") }}
|
||||
|
||||
::: order.models.SalesOrderShipment.report_context
|
||||
options:
|
||||
show_source: True
|
||||
For the fields and properties available on the underlying `SalesOrderShipment` instance, refer to the [Model Context](model_context.md#sales-order-shipment) page.
|
||||
|
||||
### Return Order
|
||||
|
||||
@@ -137,21 +126,23 @@ When printing a report or label against a [ReturnOrder](../sales/return_order.md
|
||||
|
||||
{{ report_context("models", "returnorder") }}
|
||||
|
||||
For the fields and properties available on the underlying `ReturnOrder` instance, refer to the [Model Context](model_context.md#return-order) page.
|
||||
|
||||
### Purchase Order
|
||||
|
||||
When printing a report or label against a [PurchaseOrder](../purchasing/purchase_order.md) object, the following context variables are available:
|
||||
|
||||
{{ report_context("models", "purchaseorder") }}
|
||||
|
||||
For the fields and properties available on the underlying `PurchaseOrder` instance, refer to the [Model Context](model_context.md#purchase-order) page.
|
||||
|
||||
### Transfer Order
|
||||
|
||||
When printing a report or label against a [TransferOrder](../stock/transfer_order.md) object, the following context variables are available:
|
||||
|
||||
{{ report_context("models", "transferorder") }}
|
||||
|
||||
::: order.models.TransferOrder.report_context
|
||||
options:
|
||||
show_source: True
|
||||
For the fields and properties available on the underlying `TransferOrder` instance, refer to the [Model Context](model_context.md#transfer-order) page.
|
||||
|
||||
### Stock Item
|
||||
|
||||
@@ -159,9 +150,7 @@ When printing a report or label against a [StockItem](../stock/index.md#stock-it
|
||||
|
||||
{{ report_context("models", "stockitem") }}
|
||||
|
||||
::: stock.models.StockItem.report_context
|
||||
options:
|
||||
show_source: True
|
||||
For the fields and properties available on the underlying `StockItem` instance, refer to the [Model Context](model_context.md#stock-item) page.
|
||||
|
||||
### Stock Location
|
||||
|
||||
@@ -169,9 +158,7 @@ When printing a report or label against a [StockLocation](../stock/index.md#stoc
|
||||
|
||||
{{ report_context("models", "stocklocation") }}
|
||||
|
||||
::: stock.models.StockLocation.report_context
|
||||
options:
|
||||
show_source: True
|
||||
For the fields and properties available on the underlying `StockLocation` instance, refer to the [Model Context](model_context.md#stock-location) page.
|
||||
|
||||
### Part
|
||||
|
||||
@@ -179,190 +166,9 @@ When printing a report or label against a [Part](../part/index.md) object, the f
|
||||
|
||||
{{ report_context("models", "part") }}
|
||||
|
||||
::: part.models.Part.report_context
|
||||
options:
|
||||
show_source: True
|
||||
|
||||
## Model Variables
|
||||
|
||||
Additional to the context variables provided directly to each template, each model type has a number of attributes and methods which can be accessed via the template.
|
||||
|
||||
For each model type, a subset of the most commonly used attributes are listed below. For a full list of attributes and methods, refer to the source code for the particular model type.
|
||||
|
||||
### Parts
|
||||
|
||||
#### Part
|
||||
|
||||
Each part object has access to a lot of context variables about the part. The following context variables are provided when accessing a `Part` object from within the template.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| name | Brief name for this part |
|
||||
| full_name | Full name for this part (including IPN, if not null and including variant, if not null) |
|
||||
| category | The [PartCategory](#part-category) object to which this part belongs
|
||||
| description | Longer form description of the part
|
||||
| keywords | Optional keywords for improving part search results
|
||||
| IPN | Internal part number (optional)
|
||||
| revision | Part revision
|
||||
| is_template | If True, this part is a 'template' part
|
||||
| link | Link to an external page with more information about this part (e.g. internal Wiki)
|
||||
| image | Image of this part
|
||||
| default_location | The default [StockLocation](#stock-location) object where the item is normally stored (may be null)
|
||||
| default_supplier | The default [SupplierPart](#supplierpart) which should be used to procure and stock this part
|
||||
| default_expiry | The default expiry duration for any StockItem instances of this part
|
||||
| minimum_stock | Minimum preferred quantity to keep in stock
|
||||
| units | Units of measure for this part (default='pcs')
|
||||
| salable | Can this part be sold to customers?
|
||||
| assembly | Can this part be build from other parts?
|
||||
| component | Can this part be used to make other parts?
|
||||
| purchaseable | Can this part be purchased from suppliers?
|
||||
| trackable | Trackable parts can have unique serial numbers assigned, etc, etc
|
||||
| active | Is this part active? Parts are deactivated instead of being deleted
|
||||
| virtual | Is this part "virtual"? e.g. a software product or similar
|
||||
| notes | Additional notes field for this part
|
||||
| creation_date | Date that this part was added to the database
|
||||
| creation_user | User who added this part to the database
|
||||
| responsible | User who is responsible for this part (optional)
|
||||
| starred | Whether the part is starred or not |
|
||||
| disabled | Whether the part is disabled or not |
|
||||
| total_stock | The total amount in stock |
|
||||
| quantity_being_built | The amount being built |
|
||||
| required_build_order_quantity | The amount required for build orders |
|
||||
| allocated_build_order_quantity | The amount allocated for build orders |
|
||||
| build_order_allocations | Query set with all build order allocations for that part |
|
||||
| required_sales_order_quantity | The amount required for sales orders |
|
||||
| allocated_sales_order_quantity | The amount allocated for sales orders |
|
||||
| available | Whether the part is available or not |
|
||||
| on_order | The amount that are on order |
|
||||
| required | The total amount required for build orders and sales orders |
|
||||
| allocated | The total amount allocated for build orders and sales orders |
|
||||
|
||||
#### Part Category
|
||||
For the fields and properties available on the underlying `Part` instance, refer to the [Model Context](model_context.md#part) page.
|
||||
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| name | Name of this category |
|
||||
| parent | Parent category |
|
||||
| default_location | Default [StockLocation](#stock-location) object for parts in this category or child categories |
|
||||
| default_keywords | Default keywords for parts created in this category |
|
||||
## Model Context
|
||||
|
||||
### Stock
|
||||
|
||||
#### StockItem
|
||||
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| parent | Link to another [StockItem](#stock-item) from which this StockItem was created |
|
||||
| part | Link to the master abstract [Part](#part) that this [StockItem](#stock-item) is an instance of |
|
||||
| supplier_part | Link to a specific [SupplierPart](#supplierpart) (optional) |
|
||||
| location | The [StockLocation](#stock-location) Where this [StockItem](#stock-item) is located |
|
||||
| quantity | Number of stocked units |
|
||||
| batch | Batch number for this [StockItem](#stock-item) |
|
||||
| serial | Unique serial number for this [StockItem](#stock-item) |
|
||||
| link | Optional URL to link to external resource |
|
||||
| updated | Date that this stock item was last updated (auto) |
|
||||
| expiry_date | Expiry date of the [StockItem](#stock-item) (optional) |
|
||||
| stocktake_date | Date of last stocktake for this item |
|
||||
| stocktake_user | User that performed the most recent stocktake |
|
||||
| delete_on_deplete | If True, [StockItem](#stock-item) will be deleted when the stock level gets to zero |
|
||||
| status | Status of this [StockItem](#stock-item) (ref: InvenTree.status_codes.StockStatus) |
|
||||
| status_label | Textual representation of the status e.g. "OK" |
|
||||
| notes | Extra notes field |
|
||||
| build | Link to a Build (if this stock item was created from a build) |
|
||||
| is_building | Boolean field indicating if this stock item is currently being built (or is "in production") |
|
||||
| purchase_order | Link to a [PurchaseOrder](#purchase-order) (if this stock item was created from a PurchaseOrder) |
|
||||
| sales_order | Link to a [SalesOrder](#sales-order) object (if the StockItem has been assigned to a SalesOrder) |
|
||||
| purchase_price | The unit purchase price for this [StockItem](#stock-item) - this is the unit price at time of purchase (if this item was purchased from an external supplier) |
|
||||
| packaging | Description of how the StockItem is packaged (e.g. "reel", "loose", "tape" etc) |
|
||||
|
||||
#### StockLocation
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| barcode | Brief payload data (e.g. for labels). Example: `{"stocklocation": 826}` where 826 is the primary key|
|
||||
| description | The description of the location |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| structural | True if the location is structural |
|
||||
|
||||
### Suppliers
|
||||
|
||||
#### Company
|
||||
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| name | Name of the company |
|
||||
| description | Longer form description |
|
||||
| website | URL for the company website |
|
||||
| primary_address | [Address](#address) object that is marked as primary address |
|
||||
| address | String format of the primary address |
|
||||
| contact | Contact Name |
|
||||
| phone | Contact phone number |
|
||||
| email | Contact email address |
|
||||
| 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 |
|
||||
| currency_code | Default currency for the company |
|
||||
| parts | Query set with all parts that the company supplies |
|
||||
|
||||
#### Address
|
||||
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| line1 | First line of the postal address |
|
||||
| line2 | Second line of the postal address |
|
||||
| postal_code | ZIP code of the city |
|
||||
| postal_city | City name |
|
||||
| country | Country name |
|
||||
|
||||
#### Contact
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| company | Company object where the contact belongs to |
|
||||
| name | First and second name of the contact |
|
||||
| phone | Phone number |
|
||||
| email | Email address |
|
||||
| role | Role of the contact |
|
||||
|
||||
#### SupplierPart
|
||||
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| part | Link to the master Part (Obsolete) |
|
||||
| source_item | The sourcing [StockItem](#stock-item) linked to this [SupplierPart](#supplierpart) instance |
|
||||
| supplier | [Company](#company) that supplies this part |
|
||||
| SKU | Stock keeping unit (supplier part number) |
|
||||
| link | Link to external website for this supplier part |
|
||||
| description | Descriptive notes field |
|
||||
| note | Longer form note field |
|
||||
| base_cost | Base charge added to order independent of quantity e.g. "Reeling Fee" |
|
||||
| multiple | Multiple that the part is provided in |
|
||||
| packaging | packaging that the part is supplied in, e.g. "Reel" |
|
||||
| pretty_name | The IPN, supplier name, supplier SKU and (if not null) manufacturer string joined by `|`. Ex. `P00037 | Company | 000021` |
|
||||
| unit_pricing | The price for one unit. |
|
||||
| price_breaks | Return the associated price breaks in the correct order |
|
||||
| has_price_breaks | Whether this [SupplierPart](#supplierpart) has price breaks |
|
||||
| manufacturer_string | Format a MPN string for this [SupplierPart](#supplierpart). Concatenates manufacture name and part number. |
|
||||
|
||||
### User
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| username | the username of the user |
|
||||
| first_name | The first name of the user |
|
||||
| last_name | The last name of the user |
|
||||
| email | The email address of the user |
|
||||
| pk | The primary key of the user |
|
||||
For the underlying fields and properties available on each of the model instances above (plus related model types such as `Address` or `SupplierPart`), refer to the [Model Context](model_context.md) page.
|
||||
|
||||
@@ -94,10 +94,23 @@ If the *Attach to Model on Print* option is enabled, a copy of the generated rep
|
||||
|
||||
The filename pattern used to generate the output `.pdf` file. Defaults to "report.pdf".
|
||||
|
||||
The filename pattern allows custom rendering with any context variables which are available to the report. For example, a test report for a particular [Stock Item](../stock/index.md#stock-item) can use the part name and serial number of the stock item when generating the report name:
|
||||
The filename pattern is rendered using the same Django template syntax (`{% raw %}{{ ... }}{% endraw %}`) as the report body, using the same context data - see the [context variables](./context_variables.md) and [model context](./model_context.md) documentation for the full list of variables available to a particular template type.
|
||||
|
||||
!!! info "Filename Pattern Context"
|
||||
The filename pattern has access to the *entire* context available to the report or label being generated - not a reduced subset.
|
||||
|
||||
For example, a test report for a particular [Stock Item](../stock/index.md#stock-item) can use the part name and serial number of the stock item when generating the report name:
|
||||
|
||||
{{ image("report/filename_pattern.png", "Report filename pattern") }}
|
||||
|
||||
Some further examples:
|
||||
|
||||
| Filename Pattern | Description |
|
||||
| --- | --- |
|
||||
| `{% raw %}{{ part.full_name }}-{{ serial }}.pdf{% endraw %}` | Combines the [Part](./model_context.md#part) name with the stock item serial number |
|
||||
| `{% raw %}PurchaseOrder-{{ reference }}.pdf{% endraw %}` | Uses the purchase order reference (this is the default pattern for the built-in Purchase Order report) |
|
||||
| `{% raw %}SalesOrder-{{ reference }}-{{ date }}.pdf{% endraw %}` | Combines the sales order reference with the current date |
|
||||
|
||||
|
||||
### Template Filters
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: Model Context
|
||||
---
|
||||
|
||||
## Model Context Variables
|
||||
|
||||
Each [report context](./context_variables.md) exposes one or more underlying model instances to the template (e.g. `order`, `part`, `item`). In addition to the explicit context variables documented on that page, templates can access any field or property of these model instances directly.
|
||||
|
||||
This page documents, for each reportable model type, the available:
|
||||
|
||||
- **Fields**: Database fields defined on the model (including those inherited from mixins / abstract base classes)
|
||||
- **Properties**: `@property` attributes which have been explicitly marked with the `@report_attribute` decorator, making them discoverable here
|
||||
|
||||
Not every attribute or method available on a model instance is listed here - only database fields, and properties explicitly marked for discovery. For a full list of attributes and methods, refer to the source code for the particular model type.
|
||||
|
||||
{{ reportable_model_context() }}
|
||||
|
||||
## Related Model Types
|
||||
|
||||
Some *related* model types are not themselves directly reportable, but are referenced by a field or `@report_attribute` property on a reportable model (e.g. `PartCategory` via `part.category`, `SupplierPart` via `part.default_supplier` or `stockitem.supplier_part`).
|
||||
|
||||
These are discovered automatically - there is no manually-maintained list of related models here. If a reportable model gains a new field or `@report_attribute` property pointing at another model, that model will automatically appear in this section.
|
||||
|
||||
{{ related_model_context() }}
|
||||
@@ -442,6 +442,75 @@ def define_env(env):
|
||||
|
||||
return ret_data
|
||||
|
||||
def render_attribute_table(attributes: dict, title: str) -> str:
|
||||
"""Render a table of field/property information, in a collapsible (collapsed by default) block.
|
||||
|
||||
Returns an empty string (including no block) if there is nothing to display.
|
||||
"""
|
||||
if not attributes:
|
||||
return ''
|
||||
|
||||
table = '| Variable | Type | Description |\n| --- | --- | --- |\n'
|
||||
|
||||
for k, v in sorted(attributes.items()):
|
||||
description = ' '.join(v['description'].split())
|
||||
table += f'| {k} | `{v["type"]}` | {description} |\n'
|
||||
|
||||
ret_data = f'??? note "{title}"\n\n'
|
||||
ret_data += textwrap.indent(table, ' ')
|
||||
|
||||
# Trailing blank line, so consecutive macro calls (e.g. fields followed by
|
||||
# properties) don't run together when the template places them on adjacent lines
|
||||
return ret_data + '\n'
|
||||
|
||||
@env.macro
|
||||
def reportable_model_context():
|
||||
"""Render the full 'reportable model types' section.
|
||||
|
||||
One heading plus fields/properties tables per model which templates can be
|
||||
rendered against (e.g. `Part`, `SalesOrder`, `StockItem`). The list of models
|
||||
comes directly from what `export_report_context` discovered via the
|
||||
`InvenTreeReportMixin`, so there is no manually-maintained per-model heading
|
||||
list to keep in sync here.
|
||||
"""
|
||||
global REPORT_CONTEXT
|
||||
|
||||
models = REPORT_CONTEXT.get('models', {})
|
||||
|
||||
ret_data = ''
|
||||
|
||||
for info in sorted(models.values(), key=lambda item: item['name']):
|
||||
ret_data += f'### {info["name"]}\n\n'
|
||||
ret_data += render_attribute_table(info.get('fields', {}), 'Fields')
|
||||
ret_data += render_attribute_table(info.get('properties', {}), 'Properties')
|
||||
|
||||
return ret_data
|
||||
|
||||
@env.macro
|
||||
def related_model_context():
|
||||
"""Render the full 'related model types' section.
|
||||
|
||||
A "related" model is one which is not itself reportable, but which is
|
||||
referenced by a field or `@report_attribute` property on a reportable model
|
||||
(e.g. `PartCategory` via `Part.category`, `SupplierPart` via `Part.default_supplier`).
|
||||
|
||||
These are discovered automatically (one hop out from the reportable models) by
|
||||
the `export_report_context` management command, so there is no manually-maintained
|
||||
list of related models to keep in sync here.
|
||||
"""
|
||||
global REPORT_CONTEXT
|
||||
|
||||
related = REPORT_CONTEXT.get('related_models', {})
|
||||
|
||||
ret_data = ''
|
||||
|
||||
for info in sorted(related.values(), key=lambda item: item['name']):
|
||||
ret_data += f'### {info["name"]}\n\n'
|
||||
ret_data += render_attribute_table(info.get('fields', {}), 'Fields')
|
||||
ret_data += render_attribute_table(info.get('properties', {}), 'Properties')
|
||||
|
||||
return ret_data
|
||||
|
||||
@env.macro
|
||||
def icon(
|
||||
source: str,
|
||||
|
||||
+2
-1
@@ -183,7 +183,8 @@ nav:
|
||||
- Labels: report/labels.md
|
||||
- Report Assets: report/assets.md
|
||||
- Report Snippets: report/snippets.md
|
||||
- Context Variables: report/context_variables.md
|
||||
- Report Context: report/context_variables.md
|
||||
- Model Context: report/model_context.md
|
||||
- Helper Functions: report/helpers.md
|
||||
- Barcodes: report/barcodes.md
|
||||
- Sample Templates: report/samples.md
|
||||
|
||||
@@ -7,13 +7,14 @@ This in turn allows report context to be documented in the InvenTree documentati
|
||||
without having to manually duplicate the information in multiple places.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import json
|
||||
from typing import get_args, get_origin, get_type_hints
|
||||
|
||||
import django.db.models
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from report.mixins import QuerySet
|
||||
from report.mixins import REPORT_ATTRIBUTE_MARKER, QuerySet
|
||||
|
||||
|
||||
def get_type_str(type_obj):
|
||||
@@ -38,6 +39,29 @@ def get_type_str(type_obj):
|
||||
return f'{type_obj.__module__}.{type_obj.__name__}'
|
||||
|
||||
|
||||
def find_related_models(type_obj) -> set:
|
||||
"""Recursively find any Django Model subclasses referenced by a type annotation.
|
||||
|
||||
Handles plain model classes, `Optional[Model]`, and `report.mixins.QuerySet[Model]`
|
||||
(and any nesting thereof), so that models referenced from a field/property type
|
||||
can be discovered for the "related model" documentation, without needing to be
|
||||
manually curated.
|
||||
"""
|
||||
found = set()
|
||||
|
||||
if type_obj is None or type_obj is type(None):
|
||||
return found
|
||||
|
||||
if inspect.isclass(type_obj) and issubclass(type_obj, django.db.models.Model):
|
||||
found.add(type_obj)
|
||||
return found
|
||||
|
||||
for arg in get_args(type_obj):
|
||||
found |= find_related_models(arg)
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def parse_docstring(docstring: str):
|
||||
"""Parse the docstring of a type object and return a dictionary of sections."""
|
||||
sections = {}
|
||||
@@ -59,6 +83,92 @@ def parse_docstring(docstring: str):
|
||||
return sections
|
||||
|
||||
|
||||
def get_report_attributes(model):
|
||||
"""Find all properties/methods on a model marked with @report_attribute.
|
||||
|
||||
This walks the full MRO of the model class, so attributes contributed by
|
||||
mixins (e.g. `InvenTreeBarcodeMixin.barcode`) are discovered too, regardless
|
||||
of whether they are also explicitly included in `report_context()`.
|
||||
|
||||
Returns a tuple of:
|
||||
|
||||
- A dict of {name: {"description": ..., "type": ...}}, ordered so that
|
||||
attributes redefined further down the MRO (i.e. on the model itself) take
|
||||
precedence over the same name defined on a base/mixin class.
|
||||
- A list of qualified names (e.g. "Part.default_supplier") for any
|
||||
@report_attribute-marked property/method which is missing a return type
|
||||
annotation.
|
||||
- A set of any Django Model subclasses referenced by the return type of a
|
||||
discovered property (e.g. `SupplierPart` for `Part.default_supplier`), used
|
||||
to auto-discover "related" models which are not themselves reportable.
|
||||
"""
|
||||
found = {}
|
||||
errors = []
|
||||
related_models = set()
|
||||
|
||||
for klass in reversed(model.__mro__):
|
||||
for name, member in vars(klass).items():
|
||||
target = member.fget if isinstance(member, property) else member
|
||||
description = getattr(target, REPORT_ATTRIBUTE_MARKER, None)
|
||||
|
||||
if description is None:
|
||||
continue
|
||||
|
||||
return_type = get_type_hints(target).get('return', None)
|
||||
|
||||
if return_type is None:
|
||||
errors.append(f'{klass.__name__}.{name}')
|
||||
else:
|
||||
related_models |= find_related_models(return_type)
|
||||
|
||||
found[name] = {
|
||||
'description': description,
|
||||
'type': get_type_str(return_type) if return_type is not None else '',
|
||||
}
|
||||
|
||||
return found, errors, related_models
|
||||
|
||||
|
||||
def get_model_field_attributes(model):
|
||||
"""Find all concrete database fields defined on a model.
|
||||
|
||||
This includes fields inherited from mixins/abstract base classes, so (for
|
||||
example) `barcode_data` and `barcode_hash` are discovered for any model
|
||||
which uses `InvenTreeBarcodeMixin`, without needing to be documented by hand.
|
||||
|
||||
Reverse relations (e.g. the 'lines' accessor on a linked order) are excluded,
|
||||
as they are not actual fields on this model's table but related querysets -
|
||||
these are documented (where relevant) via `report_context()` or `@report_attribute` instead.
|
||||
|
||||
Returns a tuple of:
|
||||
|
||||
- A dict of {name: {"description": ..., "type": ...}}.
|
||||
- A set of any Django Model subclasses referenced by a relation field (e.g.
|
||||
`PartCategory` for `Part.category`), used to auto-discover "related" models
|
||||
which are not themselves reportable.
|
||||
"""
|
||||
found = {}
|
||||
related_models = set()
|
||||
|
||||
for field in model._meta.get_fields():
|
||||
if not field.concrete:
|
||||
continue
|
||||
|
||||
description = str(getattr(field, 'help_text', '') or '') or str(
|
||||
getattr(field, 'verbose_name', field.name)
|
||||
)
|
||||
|
||||
type_str = type(field).__name__
|
||||
|
||||
if field.is_relation and field.related_model is not None:
|
||||
type_str = f'{type_str}[{get_type_str(field.related_model)}]'
|
||||
related_models.add(field.related_model)
|
||||
|
||||
found[field.name] = {'description': description, 'type': type_str}
|
||||
|
||||
return found, related_models
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Extract report context information, and export to a JSON file."""
|
||||
|
||||
@@ -79,8 +189,10 @@ class Command(BaseCommand):
|
||||
ReportContextExtension,
|
||||
)
|
||||
|
||||
context = {'models': {}, 'base': {}}
|
||||
context = {'models': {}, 'related_models': {}, 'base': {}}
|
||||
is_error = False
|
||||
is_attribute_error = False
|
||||
related_model_classes = set()
|
||||
|
||||
# Base context models
|
||||
for key, model in [
|
||||
@@ -111,10 +223,23 @@ class Command(BaseCommand):
|
||||
is_error = True
|
||||
continue
|
||||
|
||||
fields, field_related = get_model_field_attributes(model)
|
||||
properties, property_errors, property_related = get_report_attributes(model)
|
||||
|
||||
related_model_classes |= field_related | property_related
|
||||
|
||||
for qualified_name in property_errors:
|
||||
print(
|
||||
f'Error: {qualified_name} is marked with @report_attribute but does not have a return type annotation'
|
||||
)
|
||||
is_attribute_error = True
|
||||
|
||||
context['models'][model_key] = {
|
||||
'key': model_key,
|
||||
'name': model_name,
|
||||
'context': {},
|
||||
'fields': fields,
|
||||
'properties': properties,
|
||||
}
|
||||
|
||||
attributes = parse_docstring(ctx_type.__doc__).get('Attributes', {})
|
||||
@@ -129,6 +254,38 @@ class Command(BaseCommand):
|
||||
'INVE-E4 - Some models associated with the `InvenTreeReportMixin` do not have a valid `report_context` return type annotation.'
|
||||
)
|
||||
|
||||
# Related models: models which are not themselves reportable, but which are
|
||||
# referenced by a field or @report_attribute property on a reportable model.
|
||||
# Discovered automatically (one hop) from the fields/properties collected above,
|
||||
# so no manual list of "related" models needs to be maintained.
|
||||
reportable_models = set(report_model_types())
|
||||
|
||||
for model in sorted(
|
||||
related_model_classes - reportable_models, key=lambda m: m.__name__
|
||||
):
|
||||
model_key = model.__name__.lower()
|
||||
|
||||
fields, _ = get_model_field_attributes(model)
|
||||
properties, property_errors, _ = get_report_attributes(model)
|
||||
|
||||
for qualified_name in property_errors:
|
||||
print(
|
||||
f'Error: {qualified_name} is marked with @report_attribute but does not have a return type annotation'
|
||||
)
|
||||
is_attribute_error = True
|
||||
|
||||
context['related_models'][model_key] = {
|
||||
'key': model_key,
|
||||
'name': str(model._meta.verbose_name).title(),
|
||||
'fields': fields,
|
||||
'properties': properties,
|
||||
}
|
||||
|
||||
if is_attribute_error:
|
||||
raise CommandError(
|
||||
'INVE-E5 - Some properties/methods marked with `@report_attribute` do not have a valid return type annotation.'
|
||||
)
|
||||
|
||||
filename = kwargs.get('filename', 'inventree_report_context.json')
|
||||
|
||||
with open(filename, 'w', encoding='utf-8') as f:
|
||||
|
||||
@@ -34,6 +34,7 @@ import InvenTree.format
|
||||
import InvenTree.helpers
|
||||
import InvenTree.helpers_model
|
||||
import InvenTree.sentry
|
||||
import report.mixins
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
|
||||
@@ -559,7 +560,8 @@ class InvenTreeParameterMixin(InvenTreePermissionCheckMixin, models.Model):
|
||||
)
|
||||
|
||||
@property
|
||||
def parameters(self) -> QuerySet:
|
||||
@report.mixins.report_attribute()
|
||||
def parameters(self) -> report.mixins.QuerySet:
|
||||
"""Return a QuerySet containing all the Parameter instances for this model.
|
||||
|
||||
This will return pre-fetched data if available (i.e. in a serializer context).
|
||||
@@ -692,7 +694,8 @@ class InvenTreeAttachmentMixin(InvenTreePermissionCheckMixin):
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def attachments(self) -> QuerySet:
|
||||
@report.mixins.report_attribute()
|
||||
def attachments(self) -> report.mixins.QuerySet:
|
||||
"""Return a queryset containing all attachments for this model."""
|
||||
return self.attachments_for_model().filter(model_id=self.pk)
|
||||
|
||||
@@ -1269,8 +1272,9 @@ class PathStringMixin(models.Model):
|
||||
)
|
||||
|
||||
@property
|
||||
@report.mixins.report_attribute()
|
||||
def parentpath(self) -> list:
|
||||
"""Get the parent path of this category.
|
||||
"""Construct the parent path of this tree node.
|
||||
|
||||
Returns:
|
||||
List of category names from the top level to the parent of this category
|
||||
@@ -1278,8 +1282,9 @@ class PathStringMixin(models.Model):
|
||||
return list(self.get_ancestors())
|
||||
|
||||
@property
|
||||
@report.mixins.report_attribute()
|
||||
def path(self) -> list:
|
||||
"""Get the complete part of this category.
|
||||
"""Construct the complete part of this tree node.
|
||||
|
||||
e.g. ["Top", "Second", "Third", "This"]
|
||||
|
||||
@@ -1468,6 +1473,7 @@ class InvenTreeBarcodeMixin(models.Model):
|
||||
return data
|
||||
|
||||
@property
|
||||
@report.mixins.report_attribute()
|
||||
def barcode(self) -> str:
|
||||
"""Format a minimal barcode string (e.g. for label printing)."""
|
||||
return self.format_barcode()
|
||||
@@ -1646,14 +1652,26 @@ class InvenTreeImageMixin(models.Model):
|
||||
verbose_name=_('Image'),
|
||||
)
|
||||
|
||||
def get_image_url(self):
|
||||
def get_image_url(self) -> str:
|
||||
"""Return the URL of the image for this object."""
|
||||
if self.image:
|
||||
return InvenTree.helpers.getMediaUrl(self.image)
|
||||
return InvenTree.helpers.getBlankImage()
|
||||
|
||||
@property
|
||||
@report.mixins.report_attribute()
|
||||
def image_url(self) -> str:
|
||||
"""Return the URL of the image for this object."""
|
||||
return self.get_image_url()
|
||||
|
||||
def get_thumbnail_url(self) -> str:
|
||||
"""Return the URL of the image thumbnail for this object."""
|
||||
if self.image:
|
||||
return InvenTree.helpers.getMediaUrl(self.image, 'thumbnail')
|
||||
return InvenTree.helpers.getBlankThumbnail()
|
||||
|
||||
@property
|
||||
@report.mixins.report_attribute()
|
||||
def thumbnail_url(self) -> str:
|
||||
"""Return the URL of the image thumbnail for this object."""
|
||||
return self.get_thumbnail_url()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import os
|
||||
from decimal import Decimal
|
||||
from typing import TypedDict
|
||||
from typing import Optional, TypedDict
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
@@ -229,7 +229,8 @@ class Company(
|
||||
)
|
||||
|
||||
@property
|
||||
def address(self):
|
||||
@report.mixins.report_attribute()
|
||||
def address(self) -> Optional[str]:
|
||||
"""Return the string representation for the primary address.
|
||||
|
||||
This property exists for backwards compatibility
|
||||
@@ -239,7 +240,8 @@ class Company(
|
||||
return str(addr) if addr is not None else None
|
||||
|
||||
@property
|
||||
def primary_address(self):
|
||||
@report.mixins.report_attribute()
|
||||
def primary_address(self) -> Optional['Address']:
|
||||
"""Returns address object of primary address for this Company."""
|
||||
# We may have a pre-fetched primary address list
|
||||
if hasattr(self, 'primary_address_list'):
|
||||
@@ -254,7 +256,8 @@ class Company(
|
||||
return self.addresses.filter(primary=True).first()
|
||||
|
||||
@property
|
||||
def currency_code(self):
|
||||
@report.mixins.report_attribute()
|
||||
def currency_code(self) -> str:
|
||||
"""Return the currency code associated with this company.
|
||||
|
||||
- If the currency code is invalid, use the default currency
|
||||
@@ -276,7 +279,8 @@ class Company(
|
||||
return InvenTree.helpers.pui_url(f'/purchasing/manufacturer/{self.id}')
|
||||
|
||||
@property
|
||||
def parts(self):
|
||||
@report.mixins.report_attribute()
|
||||
def parts(self) -> report.mixins.QuerySet['SupplierPart']:
|
||||
"""Return SupplierPart objects which are supplied or manufactured by this company."""
|
||||
return SupplierPart.objects.filter(
|
||||
Q(supplier=self.id) | Q(manufacturer_part__manufacturer=self.id)
|
||||
|
||||
@@ -478,8 +478,9 @@ class Order(
|
||||
)
|
||||
|
||||
@property
|
||||
def is_overdue(self):
|
||||
"""Method to determine if this order is overdue.
|
||||
@report.mixins.report_attribute()
|
||||
def is_overdue(self) -> bool:
|
||||
"""Determine if this order is overdue.
|
||||
|
||||
Makes use of the overdue_filter() method to avoid code duplication
|
||||
"""
|
||||
@@ -587,20 +588,23 @@ class Order(
|
||||
)
|
||||
|
||||
@property
|
||||
def company(self):
|
||||
"""Return the company associated with this order.
|
||||
@report.mixins.report_attribute()
|
||||
def company(self) -> Optional[Company]:
|
||||
"""The company associated with this order.
|
||||
|
||||
This method must be implemented by any subclass, as the 'company' field may be named differently for different order types (e.g. supplier vs customer).
|
||||
"""
|
||||
raise NotImplementedError(f'company() method not implemented for {__class__}')
|
||||
|
||||
@property
|
||||
def order_address(self):
|
||||
"""Return the Address associated with this order."""
|
||||
@report.mixins.report_attribute()
|
||||
def order_address(self) -> Optional[Address]:
|
||||
"""The Address associated with this order."""
|
||||
return self.address or self.company.primary_address
|
||||
|
||||
@property
|
||||
def status_text(self):
|
||||
@report.mixins.report_attribute()
|
||||
def status_text(self) -> str:
|
||||
"""Return the text representation of the current status. This will consider any custom status."""
|
||||
if self.get_custom_status() is not None:
|
||||
from generic.states.custom import (
|
||||
|
||||
@@ -9,7 +9,7 @@ import os
|
||||
import re
|
||||
from datetime import timedelta
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
from typing import TypedDict, cast
|
||||
from typing import Optional, TypedDict, cast
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
@@ -35,6 +35,7 @@ from mptt.models import TreeForeignKey
|
||||
|
||||
import common.currency
|
||||
import common.models
|
||||
import company.models
|
||||
import InvenTree.conversion
|
||||
import InvenTree.fields
|
||||
import InvenTree.helpers
|
||||
@@ -1003,6 +1004,7 @@ class Part(
|
||||
return InvenTree.helpers.increment_serial_number(sn, self)
|
||||
|
||||
@property
|
||||
@report.mixins.report_attribute()
|
||||
def full_name(self) -> str:
|
||||
"""Format a 'full name' for this Part based on the format PART_NAME_FORMAT defined in InvenTree settings."""
|
||||
return part_helpers.render_part_full_name(self)
|
||||
@@ -1214,7 +1216,8 @@ class Part(
|
||||
return None
|
||||
|
||||
@property
|
||||
def default_supplier(self):
|
||||
@report.mixins.report_attribute()
|
||||
def default_supplier(self) -> Optional[company.models.SupplierPart]:
|
||||
"""Return the default (primary) SupplierPart for this Part.
|
||||
|
||||
This function is included for backwards compatibility,
|
||||
@@ -1369,14 +1372,15 @@ class Part(
|
||||
)
|
||||
|
||||
@property
|
||||
def category_path(self):
|
||||
def category_path(self) -> str:
|
||||
"""Return the category path of this Part instance."""
|
||||
if self.category:
|
||||
return self.category.pathstring
|
||||
return ''
|
||||
|
||||
@property
|
||||
def available_stock(self):
|
||||
@report.mixins.report_attribute()
|
||||
def available_stock(self) -> Decimal:
|
||||
"""Return the total available stock.
|
||||
|
||||
- This subtracts stock which is already allocated to builds
|
||||
@@ -1614,7 +1618,8 @@ class Part(
|
||||
PartStar.objects.filter(part=self, user=user).delete()
|
||||
|
||||
@property
|
||||
def can_build(self):
|
||||
@report.mixins.report_attribute()
|
||||
def can_build(self) -> Decimal:
|
||||
"""Return the number of units that can be build with available stock."""
|
||||
import part.filters
|
||||
|
||||
@@ -1646,7 +1651,7 @@ class Part(
|
||||
return int(max(can_build_quantity, 0))
|
||||
|
||||
@property
|
||||
def active_builds(self):
|
||||
def active_builds(self) -> int:
|
||||
"""Return a list of outstanding builds.
|
||||
|
||||
Builds marked as 'complete' or 'cancelled' are ignored
|
||||
@@ -1654,12 +1659,10 @@ class Part(
|
||||
return self.builds.filter(status__in=BuildStatusGroups.ACTIVE_CODES)
|
||||
|
||||
@property
|
||||
def quantity_being_built(self, include_variants: bool = True):
|
||||
@report.mixins.report_attribute()
|
||||
def quantity_being_built(self) -> Decimal:
|
||||
"""Return the current number of parts currently being built.
|
||||
|
||||
Arguments:
|
||||
include_variants: If True, include variants of this part in the calculation
|
||||
|
||||
Note: This is the total quantity of Build orders, *not* the number of build outputs.
|
||||
In this fashion, it is the "projected" quantity of builds
|
||||
"""
|
||||
@@ -1667,12 +1670,8 @@ class Part(
|
||||
status__in=BuildStatusGroups.ACTIVE_CODES
|
||||
)
|
||||
|
||||
if include_variants:
|
||||
# If we are including variants, get all parts in the variant tree
|
||||
# We are including variants, get all parts in the variant tree
|
||||
builds = builds.filter(part__in=self.get_descendants(include_self=True))
|
||||
else:
|
||||
# Only look at this part
|
||||
builds = builds.filter(part=self)
|
||||
|
||||
quantity = 0
|
||||
|
||||
@@ -1683,7 +1682,7 @@ class Part(
|
||||
return quantity
|
||||
|
||||
@property
|
||||
def quantity_in_production(self, include_variants: bool = True):
|
||||
def quantity_in_production(self, include_variants: bool = True) -> Decimal:
|
||||
"""Quantity of this part currently actively in production.
|
||||
|
||||
Arguments:
|
||||
@@ -1877,7 +1876,8 @@ class Part(
|
||||
return query['t']
|
||||
|
||||
@property
|
||||
def total_stock(self):
|
||||
@report.mixins.report_attribute()
|
||||
def total_stock(self) -> Decimal:
|
||||
"""Return the total stock quantity for this part.
|
||||
|
||||
- Part may be stored in multiple locations
|
||||
@@ -2030,7 +2030,7 @@ class Part(
|
||||
return list(parts)
|
||||
|
||||
@property
|
||||
def has_bom(self):
|
||||
def has_bom(self) -> bool:
|
||||
"""Return True if this Part instance has any BOM items."""
|
||||
return self.get_bom_items().exists()
|
||||
|
||||
@@ -2042,7 +2042,7 @@ class Part(
|
||||
return queryset
|
||||
|
||||
@property
|
||||
def has_trackable_parts(self):
|
||||
def has_trackable_parts(self) -> bool:
|
||||
"""Return True if any parts linked in the Bill of Materials are trackable.
|
||||
|
||||
This is important when building the part.
|
||||
@@ -2050,16 +2050,16 @@ class Part(
|
||||
return self.get_trackable_parts().exists()
|
||||
|
||||
@property
|
||||
def bom_count(self):
|
||||
def bom_count(self) -> int:
|
||||
"""Return the number of items contained in the BOM for this part."""
|
||||
return self.get_bom_items().count()
|
||||
|
||||
@property
|
||||
def used_in_count(self):
|
||||
def used_in_count(self) -> int:
|
||||
"""Return the number of part BOMs that this part appears in."""
|
||||
return len(self.get_used_in())
|
||||
|
||||
def get_bom_hash(self):
|
||||
def get_bom_hash(self) -> str:
|
||||
"""Return a checksum hash for the BOM for this part.
|
||||
|
||||
Used to determine if the BOM has changed (and needs to be signed off!)
|
||||
@@ -2444,7 +2444,8 @@ class Part(
|
||||
return orders
|
||||
|
||||
@property
|
||||
def on_order(self):
|
||||
@report.mixins.report_attribute()
|
||||
def on_order(self) -> Decimal:
|
||||
"""Return the total number of items on order for this part.
|
||||
|
||||
Note that some supplier parts may have a different pack_quantity attribute,
|
||||
|
||||
@@ -1,11 +1,45 @@
|
||||
"""Report mixin classes."""
|
||||
|
||||
from typing import Generic, TypedDict, TypeVar
|
||||
from collections.abc import Callable
|
||||
from typing import Generic, Optional, TypedDict, TypeVar
|
||||
|
||||
from django.db import models
|
||||
|
||||
_Model = TypeVar('_Model', bound=models.Model, covariant=True)
|
||||
|
||||
REPORT_ATTRIBUTE_MARKER = '_report_attribute_description'
|
||||
|
||||
|
||||
def report_attribute(description: Optional[str] = None) -> Callable:
|
||||
"""Mark a model property or method as discoverable for report documentation.
|
||||
|
||||
Unlike `InvenTreeReportMixin.report_context`, which only documents context data
|
||||
explicitly assembled for a particular model, this decorator can be applied to any
|
||||
property or method (including those defined on mixins shared by multiple models)
|
||||
to make it show up in the generated report context documentation.
|
||||
|
||||
If no description is provided, the first line of the decorated function's
|
||||
docstring is used instead.
|
||||
|
||||
When decorating a `@property`, this decorator must be applied to the underlying
|
||||
function, i.e. *below* the `@property` decorator:
|
||||
|
||||
Example:
|
||||
```python
|
||||
@property
|
||||
@report_attribute(description="Format a minimal barcode string")
|
||||
def barcode(self) -> str:
|
||||
...
|
||||
```
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
doc = (func.__doc__ or '').strip().splitlines()
|
||||
setattr(func, REPORT_ATTRIBUTE_MARKER, description or (doc[0] if doc else ''))
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class QuerySet(Generic[_Model]):
|
||||
"""A custom QuerySet class used for type hinting in report context definitions.
|
||||
|
||||
@@ -227,6 +227,7 @@ class StockLocation(
|
||||
)
|
||||
|
||||
@property
|
||||
@report.mixins.report_attribute()
|
||||
def icon(self) -> str:
|
||||
"""Get the current icon used for this location.
|
||||
|
||||
@@ -325,7 +326,8 @@ class StockLocation(
|
||||
return self.get_stock_items(cascade).count()
|
||||
|
||||
@property
|
||||
def item_count(self):
|
||||
@report.mixins.report_attribute()
|
||||
def item_count(self) -> int:
|
||||
"""Simply returns the number of stock items in this location.
|
||||
|
||||
Required for tree view serializer.
|
||||
@@ -865,7 +867,8 @@ class StockItem(
|
||||
return self.get_next_serialized_item(reverse=True)
|
||||
|
||||
@property
|
||||
def status_label(self):
|
||||
@report.mixins.report_attribute()
|
||||
def status_label(self) -> str:
|
||||
"""Return label."""
|
||||
return StockStatus.label(self.status)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user