2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-16 12:03:08 +00:00

Typed report context (#9431)

* add typed report context

* make it py3.9 compatible

* fix docs

* debug docs

* fix for py 3.9

* add requested error codes
This commit is contained in:
Lukas 2025-04-02 22:45:37 +02:00 committed by GitHub
parent b2db0b67e0
commit 75b47f8d09
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 581 additions and 154 deletions

5
docs/.gitignore vendored
View File

@ -25,3 +25,8 @@ inventree_settings.json
inventree_tags.yml inventree_tags.yml
.vscode/ .vscode/
inventree_filters.yml
inventree_report_context.json
inventree_settings.json
inventree_tags.yml

View File

@ -11,16 +11,7 @@ Context variables are provided to each template when it is rendered. The availab
In addition to the model-specific context variables, the following global context variables are available to all templates: In addition to the model-specific context variables, the following global context variables are available to all templates:
| Variable | Description | {{ report_context("base", "global") }}
| --- | --- |
| base_url | The base URL for the InvenTree instance |
| date | Current date, represented as a Python datetime.date object |
| datetime | Current datetime, represented as a Python datetime object |
| template | The report template instance which is being rendered against |
| template_description | Description of the report template |
| template_name | Name of the report template |
| template_revision | Revision of the report template |
| user | User who made the request to render the template |
::: report.models.ReportTemplateBase.base_context ::: report.models.ReportTemplateBase.base_context
options: options:
@ -30,10 +21,7 @@ In addition to the model-specific context variables, the following global contex
In addition to the [global context](#global-context), all *report* templates have access to the following context variables: In addition to the [global context](#global-context), all *report* templates have access to the following context variables:
| Variable | Description | {{ report_context("base", "report") }}
| --- | --- |
| page_size | The page size of the report |
| landscape | Boolean value, True if the report is in landscape mode |
Note that custom plugins may also add additional context variables to the report context. Note that custom plugins may also add additional context variables to the report context.
@ -45,10 +33,7 @@ Note that custom plugins may also add additional context variables to the report
In addition to the [global context](#global-context), all *label* templates have access to the following context variables: In addition to the [global context](#global-context), all *label* templates have access to the following context variables:
| Variable | Description | {{ report_context("base", "label") }}
| --- | --- |
| width | The width of the label (in mm) |
| height | The height of the label (in mm) |
Note that custom plugins may also add additional context variables to the label context. Note that custom plugins may also add additional context variables to the label context.
@ -56,7 +41,6 @@ Note that custom plugins may also add additional context variables to the label
options: options:
show_source: True show_source: True
## Template Types ## Template Types
Templates (whether for generating [reports](./report.md) or [labels](./labels.md)) are rendered against a particular "model" type. The following model types are supported, and can have templates renderer against them: Templates (whether for generating [reports](./report.md) or [labels](./labels.md)) are rendered against a particular "model" type. The following model types are supported, and can have templates renderer against them:
@ -76,19 +60,7 @@ Templates (whether for generating [reports](./report.md) or [labels](./labels.md
When printing a report or label against a [Build Order](../build/build.md) object, the following context variables are available: When printing a report or label against a [Build Order](../build/build.md) object, the following context variables are available:
| Variable | Description | {{ report_context("models", "build") }}
| --- | --- |
| bom_items | Query set of all BuildItem objects associated with the BuildOrder |
| build | The BuildOrder instance itself |
| build_outputs | Query set of all BuildItem objects associated with the BuildOrder |
| line_items | Query set of all build line items associated with the BuildOrder |
| part | The Part object which is being assembled in the build order |
| quantity | The total quantity of the part being assembled |
| reference | The reference field of the BuildOrder |
| title | The title field of the BuildOrder |
| build.creation_date | The date when the build was created |
| build.target_date | The given target date of the build order |
| build.completion_date | The date when the build was completed |
::: build.models.Build.report_context ::: build.models.Build.report_context
options: options:
@ -98,145 +70,69 @@ When printing a report or label against a [Build Order](../build/build.md) objec
When printing a report or label against a [BuildOrderLineItem](../build/build.md) object, the following context variables are available: When printing a report or label against a [BuildOrderLineItem](../build/build.md) object, the following context variables are available:
| Variable | Description | {{ report_context("models", "buildline") }}
| --- | --- |
| allocated_quantity | The quantity of the part which has been allocated to this build |
| allocations | A query set of all StockItem objects which have been allocated to this build line |
| bom_item | The BomItem associated with this line item |
| build | The BuildOrder instance associated with this line item |
| build_line | The build line instance itself |
| part | The sub-part (component) associated with the linked BomItem instance |
| quantity | The quantity required for this line item |
::: build.models.BuildLine.report_context ::: build.models.BuildLine.report_context
options: options:
show_source: True show_source: True
### Sales Order ### Sales Order
When printing a report or label against a [SalesOrder](../order/sales_order.md) object, the following context variables are available: When printing a report or label against a [SalesOrder](../order/sales_order.md) object, the following context variables are available:
| Variable | Description | {{ report_context("models", "salesorder") }}
| --- | --- |
| customer | The customer object associated with the SalesOrder |
| description | The description field of the SalesOrder |
| extra_lines | Query set of all extra lines associated with the SalesOrder |
| lines | Query set of all line items associated with the SalesOrder |
| order | The SalesOrder instance itself |
| reference | The reference field of the SalesOrder |
| title | The title (string representation) of the SalesOrder |
| order.creation_date | The date when the order was created |
| order.target_date | The given target date |
| order.shipment_date | The date when the order was shipped to the customer |
::: order.models.Order.report_context ::: order.models.Order.report_context
options: options:
show_source: True show_source: True
### Sales Order Shipment
When printing a report or label against a [SalesOrderShipment](../order/sales_order.md#sales-order-shipments) object, the following context variables are available:
{{ report_context("models", "salesordershipment") }}
::: order.models.SalesOrderShipment.report_context
options:
show_source: True
### Return Order ### Return Order
When printing a report or label against a [ReturnOrder](../order/return_order.md) object, the following context variables are available: When printing a report or label against a [ReturnOrder](../order/return_order.md) object, the following context variables are available:
| Variable | Description | {{ report_context("models", "returnorder") }}
| --- | --- |
| customer | The customer object associated with the ReturnOrder |
| description | The description field of the ReturnOrder |
| extra_lines | Query set of all extra lines associated with the ReturnOrder |
| lines | Query set of all line items associated with the ReturnOrder |
| order | The ReturnOrder instance itself |
| reference | The reference field of the ReturnOrder |
| title | The title (string representation) of the ReturnOrder |
| order.creation_date | The date when the order was created |
| order.target_date | The given target date |
| order.issue_date | The date when the return order was issued |
### Purchase Order ### Purchase Order
When printing a report or label against a [PurchaseOrder](../order/purchase_order.md) object, the following context variables are available: When printing a report or label against a [PurchaseOrder](../order/purchase_order.md) object, the following context variables are available:
| Variable | Description | {{ report_context("models", "purchaseorder") }}
| --- | --- |
| description | The description field of the PurchaseOrder |
| extra_lines | Query set of all extra lines associated with the PurchaseOrder |
| lines | Query set of all line items associated with the PurchaseOrder |
| order | The PurchaseOrder instance itself |
| reference | The reference field of the PurchaseOrder |
| supplier | The supplier object associated with the PurchaseOrder |
| title | The title (string representation) of the PurchaseOrder |
| order.creation_date | The date when the order was created |
| order.target_date | The given target date |
| order.issue_date | The date then the order was issued |
| order.complete_date | The date then the order was received and completed |
### Stock Item ### Stock Item
When printing a report or label against a [StockItem](../stock/stock.md#stock-item) object, the following context variables are available: When printing a report or label against a [StockItem](../stock/stock.md#stock-item) object, the following context variables are available:
| Variable | Description | {{ report_context("models", "stockitem") }}
| --- | --- |
| barcode_data | Generated barcode data for the StockItem |
| barcode_hash | Hash of the barcode data |
| batch | The batch code for the StockItem |
| child_items | Query set of all StockItem objects which are children of this StockItem |
| ipn | The IPN (internal part number) of the associated Part |
| installed_items | Query set of all StockItem objects which are installed in this StockItem |
| item | The StockItem object itself |
| name | The name of the associated Part |
| part | The Part object which is associated with the StockItem |
| qr_data | Generated QR code data for the StockItem |
| qr_url | Generated URL for embedding in a QR code |
| parameters | Dict object containing the parameters associated with the base Part |
| quantity | The quantity of the StockItem |
| result_list | FLattened list of TestResult data associated with the stock item |
| results | Dict object of TestResult data associated with the StockItem |
| serial | The serial number of the StockItem |
| stock_item | The StockItem object itself (shadow of 'item') |
| tests | Dict object of TestResult data associated with the StockItem (shadow of 'results') |
| test_keys | List of test keys associated with the StockItem |
| test_template_list | List of test templates associated with the StockItem |
| test_templates | Dict object of test templates associated with the StockItem |
::: stock.models.StockItem.report_context ::: stock.models.StockItem.report_context
options: options:
show_source: True show_source: True
### Stock Location ### Stock Location
When printing a report or label against a [StockLocation](../stock/stock.md#stock-location) object, the following context variables are available: When printing a report or label against a [StockLocation](../stock/stock.md#stock-location) object, the following context variables are available:
| Variable | Description | {{ report_context("models", "stocklocation") }}
| --- | --- |
| location | The StockLocation object itself |
| qr_data | Formatted QR code data for the StockLocation |
| parent | The parent StockLocation object |
| stock_location | The StockLocation object itself (shadow of 'location') |
| stock_items | Query set of all StockItem objects which are located in the StockLocation |
::: stock.models.StockLocation.report_context ::: stock.models.StockLocation.report_context
options: options:
show_source: True show_source: True
### Part ### Part
When printing a report or label against a [Part](../part/part.md) object, the following context variables are available: When printing a report or label against a [Part](../part/part.md) object, the following context variables are available:
| Variable | Description | {{ report_context("models", "part") }}
| --- | --- |
| bom_items | Query set of all BomItem objects associated with the Part |
| category | The PartCategory object associated with the Part |
| description | The description field of the Part |
| IPN | The IPN (internal part number) of the Part |
| name | The name of the Part |
| parameters | Dict object containing the parameters associated with the Part |
| part | The Part object itself |
| qr_data | Formatted QR code data for the Part |
| qr_url | Generated URL for embedding in a QR code |
| revision | The revision of the Part |
| test_template_list | List of test templates associated with the Part |
| test_templates | Dict object of test templates associated with the Part |
::: part.models.Part.report_context ::: part.models.Part.report_context
options: options:

View File

@ -22,6 +22,16 @@ Raise an issue if none of these options work.
The used invoke executable is the wrong one. InvenTree needs to have The used invoke executable is the wrong one. InvenTree needs to have
You probably have a reference to invoke or a directory with invoke in your PATH variable that is not in InvenTrees virtual environment. You can check this by running `which invoke` and `which python` in your installations base directory and compare the output. If they are not the same, you need to adjust your PATH variable to point to the correct virtual environment before it lists other directories with invoke. You probably have a reference to invoke or a directory with invoke in your PATH variable that is not in InvenTrees virtual environment. You can check this by running `which invoke` and `which python` in your installations base directory and compare the output. If they are not the same, you need to adjust your PATH variable to point to the correct virtual environment before it lists other directories with invoke.
#### INVE-E3
**Report Context use custom QuerySet**
As the `django.db.models.QuerySet` is not a generic class, we would loose type information without `django-stubs`. Therefore use the `report.mixins.QuerySet` generic class when typing a report context.
#### INVE-E4
**Model missing report_context return type annotation**
Models that implement the `InvenTreeReportMixin` must have an explicit return type annotation for the `report_context` function.
### INVE-W (InvenTree Warning) ### INVE-W (InvenTree Warning)
Warnings - These are non-critical errors which should be addressed when possible. Warnings - These are non-critical errors which should be addressed when possible.

View File

@ -4,6 +4,7 @@ import json
import os import os
import subprocess import subprocess
import textwrap import textwrap
from typing import Literal
import requests import requests
import yaml import yaml
@ -33,6 +34,7 @@ global GLOBAL_SETTINGS
global USER_SETTINGS global USER_SETTINGS
global TAGS global TAGS
global FILTERS global FILTERS
global REPORT_CONTEXT
# Read in the InvenTree settings file # Read in the InvenTree settings file
here = os.path.dirname(__file__) here = os.path.dirname(__file__)
@ -50,6 +52,9 @@ with open(os.path.join(here, 'inventree_tags.yml'), encoding='utf-8') as f:
# Filters # Filters
with open(os.path.join(here, 'inventree_filters.yml'), encoding='utf-8') as f: with open(os.path.join(here, 'inventree_filters.yml'), encoding='utf-8') as f:
FILTERS = yaml.load(f, yaml.BaseLoader) FILTERS = yaml.load(f, yaml.BaseLoader)
# Report context
with open(os.path.join(here, 'inventree_report_context.json'), encoding='utf-8') as f:
REPORT_CONTEXT = json.load(f)
def get_repo_url(raw=False): def get_repo_url(raw=False):
@ -334,3 +339,16 @@ def define_env(env):
ret_data += '\n' ret_data += '\n'
return ret_data return ret_data
@env.macro
def report_context(type_: Literal['models', 'base'], model: str):
"""Extract information on a particular report context."""
global REPORT_CONTEXT
context = REPORT_CONTEXT.get(type_).get(model)
ret_data = '| Variable | Type | Description |\n| --- | --- | --- |\n'
for k, v in context['context'].items():
ret_data += f'| {k} | `{v["type"]}` | {v["description"]} |\n'
return ret_data

View File

@ -56,6 +56,8 @@ ignore = [
# - RUF032 - decimal-from-float-literal # - RUF032 - decimal-from-float-literal
"RUF032", "RUF032",
"RUF045", "RUF045",
# - UP045 - Use `X | None` instead of `Optional[X]`
"UP045",
# TODO These should be followed up and fixed # TODO These should be followed up and fixed
# - B904 Within an `except` clause, raise exceptions # - B904 Within an `except` clause, raise exceptions

View File

@ -2,7 +2,7 @@
import io import io
from decimal import Decimal from decimal import Decimal
from typing import Optional from typing import Optional, cast
from urllib.parse import urljoin from urllib.parse import urljoin
from django.conf import settings from django.conf import settings
@ -27,7 +27,7 @@ from InvenTree.format import format_money
logger = structlog.get_logger('inventree') logger = structlog.get_logger('inventree')
def get_base_url(request=None): def get_base_url(request=None) -> str:
"""Return the base URL for the InvenTree server. """Return the base URL for the InvenTree server.
The base URL is determined in the following order of decreasing priority: The base URL is determined in the following order of decreasing priority:
@ -56,7 +56,7 @@ def get_base_url(request=None):
# Check if a global InvenTree setting is provided # Check if a global InvenTree setting is provided
try: try:
if site_url := get_global_setting('INVENTREE_BASE_URL', create=False): if site_url := get_global_setting('INVENTREE_BASE_URL', create=False):
return site_url return cast(str, site_url)
except (ProgrammingError, OperationalError): except (ProgrammingError, OperationalError):
pass pass

View File

@ -0,0 +1,141 @@
"""Custom management command to export the available report context.
This is used to generate a JSON file which contains all available report
context, so that they can be introspected by the InvenTree documentation system.
This in turn allows report context to be documented in the InvenTree documentation,
without having to manually duplicate the information in multiple places.
"""
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
def get_type_str(type_obj):
"""Get the type str of a type object, including any generic parameters."""
if type_obj is django.db.models.QuerySet:
raise CommandError(
'INVE-E3 - Do not use django.db.models.QuerySet directly for typing, use report.mixins.QuerySet instead.'
)
if origin := get_origin(type_obj):
# use abbreviated name for QuerySet to save space
origin_str = 'QuerySet' if origin is QuerySet else get_type_str(origin)
return f'{origin_str}[{", ".join(get_type_str(arg) for arg in get_args(type_obj))}]'
if type_obj is type(None):
return 'None'
if type_obj.__module__ == 'builtins':
return type_obj.__name__
# in python3.9, typing.Union has no __name__
if not hasattr(type_obj, '__module__') or not hasattr(type_obj, '__name__'):
return str(type_obj)
return f'{type_obj.__module__}.{type_obj.__name__}'
def parse_docstring(docstring: str):
"""Parse the docstring of a type object and return a dictionary of sections."""
sections = {}
current_section = None
for line in docstring.splitlines():
stripped = line.strip()
if not stripped:
continue
if stripped.endswith(':'):
current_section = stripped.rstrip(':')
sections[current_section] = {}
elif ':' in stripped and current_section:
name, doc = stripped.split(':', 1)
sections[current_section][name.strip()] = doc.strip()
return sections
class Command(BaseCommand):
"""Extract report context information, and export to a JSON file."""
def add_arguments(self, parser):
"""Add custom arguments for this command."""
parser.add_argument(
'filename',
type=str,
help='Output filename for the report context definitions',
)
def handle(self, *args, **kwargs):
"""Export report context information to a JSON file."""
from report.helpers import report_model_types
from report.models import (
BaseContextExtension,
LabelContextExtension,
ReportContextExtension,
)
context = {'models': {}, 'base': {}}
is_error = False
# Base context models
for key, model in [
('global', BaseContextExtension),
('report', ReportContextExtension),
('label', LabelContextExtension),
]:
context['base'][key] = {'key': key, 'context': {}}
attributes = parse_docstring(model.__doc__).get('Attributes', {})
for k, v in get_type_hints(model).items():
context['base'][key]['context'][k] = {
'description': attributes.get(k, ''),
'type': get_type_str(v),
}
# Report context models
for model in report_model_types():
model_key = model.__name__.lower()
model_name = str(model._meta.verbose_name)
if (
ctx_type := get_type_hints(model.report_context).get('return', None)
) is None:
print(
f'Error: Model {model}.report_context does not have a return type annotation'
)
is_error = True
continue
context['models'][model_key] = {
'key': model_key,
'name': model_name,
'context': {},
}
attributes = parse_docstring(ctx_type.__doc__).get('Attributes', {})
for k, v in get_type_hints(ctx_type).items():
context['models'][model_key]['context'][k] = {
'description': attributes.get(k, ''),
'type': get_type_str(v),
}
if is_error:
raise CommandError(
'INVE-E4 - Some models associated with the `InvenTreeReportMixin` do not have a valid `report_context` return type annotation.'
)
filename = kwargs.get('filename', 'inventree_report_context.json')
with open(filename, 'w', encoding='utf-8') as f:
json.dump(context, f, indent=4)
print(f"Exported InvenTree report context definitions to '{filename}'")

View File

@ -947,7 +947,7 @@ class InvenTreeBarcodeMixin(models.Model):
) )
def format_barcode(self, **kwargs): def format_barcode(self, **kwargs):
"""Return a JSON string for formatting a QR code for this model instance.""" """Return a string for formatting a QR code for this model instance."""
from plugin.base.barcodes.helper import generate_barcode from plugin.base.barcodes.helper import generate_barcode
return generate_barcode(self) return generate_barcode(self)
@ -966,7 +966,7 @@ class InvenTreeBarcodeMixin(models.Model):
return data return data
@property @property
def barcode(self): def barcode(self) -> str:
"""Format a minimal barcode string (e.g. for label printing).""" """Format a minimal barcode string (e.g. for label printing)."""
return self.format_barcode() return self.format_barcode()

View File

@ -49,6 +49,30 @@ from stock.status_codes import StockHistoryCode, StockStatus
logger = structlog.get_logger('inventree') logger = structlog.get_logger('inventree')
class BuildReportContext(report.mixins.BaseReportContext):
"""Context for the Build model.
Attributes:
bom_items: Query set of all BuildItem objects associated with the BuildOrder
build: The BuildOrder instance itself
build_outputs: Query set of all BuildItem objects associated with the BuildOrder
line_items: Query set of all build line items associated with the BuildOrder
part: The Part object which is being assembled in the build order
quantity: The total quantity of the part being assembled
reference: The reference field of the BuildOrder
title: The title field of the BuildOrder
"""
bom_items: report.mixins.QuerySet[part.models.BomItem]
build: 'Build'
build_outputs: report.mixins.QuerySet[stock.models.StockItem]
line_items: report.mixins.QuerySet['BuildLine']
part: part.models.Part
quantity: int
reference: str
title: str
class Build( class Build(
report.mixins.InvenTreeReportMixin, report.mixins.InvenTreeReportMixin,
InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeAttachmentMixin,
@ -183,7 +207,7 @@ class Build(
'target_date': _('Target date must be after start date') 'target_date': _('Target date must be after start date')
}) })
def report_context(self) -> dict: def report_context(self) -> BuildReportContext:
"""Generate custom report context data.""" """Generate custom report context data."""
return { return {
'bom_items': self.part.get_bom_items(), 'bom_items': self.part.get_bom_items(),
@ -1454,6 +1478,28 @@ def after_save_build(sender, instance: Build, created: bool, **kwargs):
instance.update_build_line_items() instance.update_build_line_items()
class BuildLineReportContext(report.mixins.BaseReportContext):
"""Context for the BuildLine model.
Attributes:
allocated_quantity: The quantity of the part which has been allocated to this build
allocations: A query set of all StockItem objects which have been allocated to this build line
bom_item: The BomItem associated with this line item
build: The BuildOrder instance associated with this line item
build_line: The build line instance itself
part: The sub-part (component) associated with the linked BomItem instance
quantity: The quantity required for this line item
"""
allocated_quantity: decimal.Decimal
allocations: report.mixins.QuerySet['BuildItem']
bom_item: part.models.BomItem
build: Build
build_line: 'BuildLine'
part: part.models.Part
quantity: decimal.Decimal
class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeModel): class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeModel):
"""A BuildLine object links a BOMItem to a Build. """A BuildLine object links a BOMItem to a Build.
@ -1481,7 +1527,7 @@ class BuildLine(report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeMo
"""Return the API URL used to access this model.""" """Return the API URL used to access this model."""
return reverse('api-build-line-list') return reverse('api-build-line-list')
def report_context(self): def report_context(self) -> BuildLineReportContext:
"""Generate custom report context for this BuildLine object.""" """Generate custom report context for this BuildLine object."""
return { return {
'allocated_quantity': self.allocated_quantity, 'allocated_quantity': self.allocated_quantity,

View File

@ -1,6 +1,7 @@
"""Order model definitions.""" """Order model definitions."""
from decimal import Decimal from decimal import Decimal
from typing import Any, Optional
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@ -177,6 +178,92 @@ class TotalPriceMixin(models.Model):
return total return total
class BaseOrderReportContext(report.mixins.BaseReportContext):
"""Base context for all order models.
Attributes:
description: The description field of the order
extra_lines: Query set of all extra lines associated with the order
lines: Query set of all line items associated with the order
order: The order instance itself
reference: The reference field of the order
title: The title (string representation) of the order
"""
description: str
extra_lines: Any
lines: Any
order: Any
reference: str
title: str
class PurchaseOrderReportContext(report.mixins.BaseReportContext):
"""Context for the purchase order model.
Attributes:
description: The description field of the PurchaseOrder
reference: The reference field of the PurchaseOrder
title: The title (string representation) of the PurchaseOrder
extra_lines: Query set of all extra lines associated with the PurchaseOrder
lines: Query set of all line items associated with the PurchaseOrder
order: The PurchaseOrder instance itself
supplier: The supplier object associated with the PurchaseOrder
"""
description: str
reference: str
title: str
extra_lines: report.mixins.QuerySet['PurchaseOrderExtraLine']
lines: report.mixins.QuerySet['PurchaseOrderLineItem']
order: 'PurchaseOrder'
supplier: Optional[Company]
class SalesOrderReportContext(report.mixins.BaseReportContext):
"""Context for the sales order model.
Attributes:
description: The description field of the SalesOrder
reference: The reference field of the SalesOrder
title: The title (string representation) of the SalesOrder
extra_lines: Query set of all extra lines associated with the SalesOrder
lines: Query set of all line items associated with the SalesOrder
order: The SalesOrder instance itself
customer: The customer object associated with the SalesOrder
"""
description: str
reference: str
title: str
extra_lines: report.mixins.QuerySet['SalesOrderExtraLine']
lines: report.mixins.QuerySet['SalesOrderLineItem']
order: 'SalesOrder'
customer: Optional[Company]
class ReturnOrderReportContext(report.mixins.BaseReportContext):
"""Context for the return order model.
Attributes:
description: The description field of the ReturnOrder
reference: The reference field of the ReturnOrder
title: The title (string representation) of the ReturnOrder
extra_lines: Query set of all extra lines associated with the ReturnOrder
lines: Query set of all line items associated with the ReturnOrder
order: The ReturnOrder instance itself
customer: The customer object associated with the ReturnOrder
"""
description: str
reference: str
title: str
extra_lines: report.mixins.QuerySet['ReturnOrderExtraLine']
lines: report.mixins.QuerySet['ReturnOrderLineItem']
order: 'ReturnOrder'
customer: Optional[Company]
class Order( class Order(
StatusCodeMixin, StatusCodeMixin,
StateTransitionMixin, StateTransitionMixin,
@ -300,7 +387,7 @@ class Order(
line.target_date = None line.target_date = None
line.order = self line.order = self
def report_context(self): def report_context(self) -> BaseOrderReportContext:
"""Generate context data for the reporting interface.""" """Generate context data for the reporting interface."""
return { return {
'description': self.description, 'description': self.description,
@ -456,7 +543,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
super().clean_line_item(line) super().clean_line_item(line)
line.received = 0 line.received = 0
def report_context(self): def report_context(self) -> PurchaseOrderReportContext:
"""Return report context data for this PurchaseOrder.""" """Return report context data for this PurchaseOrder."""
return {**super().report_context(), 'supplier': self.supplier} return {**super().report_context(), 'supplier': self.supplier}
@ -979,7 +1066,7 @@ class SalesOrder(TotalPriceMixin, Order):
super().clean_line_item(line) super().clean_line_item(line)
line.shipped = 0 line.shipped = 0
def report_context(self): def report_context(self) -> SalesOrderReportContext:
"""Generate report context data for this SalesOrder.""" """Generate report context data for this SalesOrder."""
return {**super().report_context(), 'customer': self.customer} return {**super().report_context(), 'customer': self.customer}
@ -1802,6 +1889,26 @@ class SalesOrderLineItem(OrderLineItem):
return self.shipped >= self.quantity return self.shipped >= self.quantity
class SalesOrderShipmentReportContext(report.mixins.BaseReportContext):
"""Context for the SalesOrderShipment model.
Attributes:
allocations: QuerySet of SalesOrderAllocation objects
order: The associated SalesOrder object
reference: Shipment reference string
shipment: The SalesOrderShipment object itself
tracking_number: Shipment tracking number string
title: Title for the report
"""
allocations: report.mixins.QuerySet['SalesOrderAllocation']
order: 'SalesOrder'
reference: str
shipment: 'SalesOrderShipment'
tracking_number: str
title: str
class SalesOrderShipment( class SalesOrderShipment(
InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeNotesMixin,
@ -1835,7 +1942,7 @@ class SalesOrderShipment(
"""Return the API URL associated with the SalesOrderShipment model.""" """Return the API URL associated with the SalesOrderShipment model."""
return reverse('api-so-shipment-list') return reverse('api-so-shipment-list')
def report_context(self): def report_context(self) -> SalesOrderShipmentReportContext:
"""Generate context data for the reporting interface.""" """Generate context data for the reporting interface."""
return { return {
'allocations': self.allocations, 'allocations': self.allocations,
@ -2198,7 +2305,7 @@ class ReturnOrder(TotalPriceMixin, Order):
line.received_date = None line.received_date = None
line.outcome = ReturnOrderLineStatus.PENDING.value line.outcome = ReturnOrderLineStatus.PENDING.value
def report_context(self): def report_context(self) -> ReturnOrderReportContext:
"""Generate report context data for this ReturnOrder.""" """Generate report context data for this ReturnOrder."""
return {**super().report_context(), 'customer': self.customer} return {**super().report_context(), 'customer': self.customer}

View File

@ -10,13 +10,14 @@ import os
import re import re
from datetime import timedelta from datetime import timedelta
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from typing import Optional, cast
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinLengthValidator, MinValueValidator from django.core.validators import MinLengthValidator, MinValueValidator
from django.db import models, transaction from django.db import models, transaction
from django.db.models import ExpressionWrapper, F, Q, Sum, UniqueConstraint from django.db.models import ExpressionWrapper, F, Q, QuerySet, Sum, UniqueConstraint
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.db.models.signals import post_delete, post_save from django.db.models.signals import post_delete, post_save
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
@ -359,6 +360,38 @@ class PartManager(TreeManager):
) )
class PartReportContext(report.mixins.BaseReportContext):
"""Context for the part model.
Attributes:
bom_items: Query set of all BomItem objects associated with the Part
category: The PartCategory object associated with the Part
description: The description field of the Part
IPN: The IPN (internal part number) of the Part
name: The name of the Part
parameters: Dict object containing the parameters associated with the Part
part: The Part object itself
qr_data: Formatted QR code data for the Part
qr_url: Generated URL for embedding in a QR code
revision: The revision of the Part
test_template_list: List of test templates associated with the Part
test_templates: Dict object of test templates associated with the Part
"""
bom_items: report.mixins.QuerySet[BomItem]
category: Optional[PartCategory]
description: str
IPN: Optional[str]
name: str
parameters: dict[str, str]
part: Part
qr_data: str
qr_url: str
revision: Optional[str]
test_template_list: report.mixins.QuerySet[PartTestTemplate]
test_templates: dict[str, PartTestTemplate]
@cleanup.ignore @cleanup.ignore
class Part( class Part(
InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeAttachmentMixin,
@ -441,10 +474,10 @@ class Part(
"""Return the associated barcode model type code for this model.""" """Return the associated barcode model type code for this model."""
return 'PA' return 'PA'
def report_context(self): def report_context(self) -> PartReportContext:
"""Return custom report context information.""" """Return custom report context information."""
return { return {
'bom_items': self.get_bom_items(), 'bom_items': cast(report.mixins.QuerySet['BomItem'], self.get_bom_items()),
'category': self.category, 'category': self.category,
'description': self.description, 'description': self.description,
'IPN': self.IPN, 'IPN': self.IPN,
@ -1731,7 +1764,7 @@ class Part(
return bom_filter return bom_filter
def get_bom_items(self, include_inherited=True): def get_bom_items(self, include_inherited=True) -> QuerySet[BomItem]:
"""Return a queryset containing all BOM items for this part. """Return a queryset containing all BOM items for this part.
By default, will include inherited BOM items By default, will include inherited BOM items
@ -2294,7 +2327,9 @@ class Part(
parameter.save() parameter.save()
def getTestTemplates(self, required=None, include_parent=True, enabled=None): def getTestTemplates(
self, required=None, include_parent=True, enabled=None
) -> QuerySet[PartTestTemplate]:
"""Return a list of all test templates associated with this Part. """Return a list of all test templates associated with this Part.
These are used for validation of a StockItem. These are used for validation of a StockItem.

View File

@ -69,7 +69,7 @@ class BarcodeMixin:
return True return True
def generate(self, model_instance: InvenTreeBarcodeMixin): def generate(self, model_instance: InvenTreeBarcodeMixin) -> str:
"""Generate barcode data for the given model instance. """Generate barcode data for the given model instance.
Arguments: Arguments:

View File

@ -1,7 +1,24 @@
"""Report mixin classes.""" """Report mixin classes."""
from typing import Generic, TypedDict, TypeVar
from django.db import models from django.db import models
_Model = TypeVar('_Model', bound=models.Model, covariant=True)
class QuerySet(Generic[_Model]):
"""A custom QuerySet class used for type hinting in report context definitions.
This will later be replaced by django.db.models.QuerySet, but as
django's QuerySet is not a generic class, we need to create our own to not
loose type data.
"""
class BaseReportContext(TypedDict):
"""Base context for a report model."""
class InvenTreeReportMixin(models.Model): class InvenTreeReportMixin(models.Model):
"""A mixin class for adding report generation functionality to a model class. """A mixin class for adding report generation functionality to a model class.
@ -15,9 +32,28 @@ class InvenTreeReportMixin(models.Model):
abstract = True abstract = True
def report_context(self) -> dict: def report_context(self) -> BaseReportContext:
"""Generate a dict of context data to provide to the reporting framework. """Generate a dict of context data to provide to the reporting framework.
The default implementation returns an empty dict object. The default implementation returns an empty dict object.
This method must contain a type annotation, as it is used by the report generation framework
to determine the type of context data that is provided to the report template.
Example:
```python
class MyModelReportContext(BaseReportContext):
my_field: str
po: order.models.PurchaseOrder
bom_items: report.mixins.QuerySet[part.models.BomItem]
class MyModel(report.mixins.InvenTreeReportMixin):
...
def report_context(self) -> MyModelReportContext:
return {
'my_field': self.my_field,
'po': self.po,
}
```
""" """
return {} return {}

View File

@ -3,8 +3,11 @@
import io import io
import os import os
import sys import sys
from datetime import date, datetime
from typing import Optional, TypedDict, cast
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
@ -123,6 +126,56 @@ class TemplateUploadMixin:
return super().validate_unique(exclude) return super().validate_unique(exclude)
class BaseContextExtension(TypedDict):
"""Base context extension.
Attributes:
base_url: The base URL for the InvenTree instance
date: Current date, represented as a Python datetime.date object
datetime: Current datetime, represented as a Python datetime object
template: The report template instance which is being rendered against
template_description: Description of the report template
template_name: Name of the report template
template_revision: Revision of the report template
user: User who made the request to render the template
"""
base_url: str
date: date
datetime: datetime
template: 'ReportTemplateBase'
template_description: str
template_name: str
template_revision: int
user: Optional[AbstractUser]
class LabelContextExtension(TypedDict):
"""Label report context extension.
Attributes:
width: The width of the label (in mm)
height: The height of the label (in mm)
page_style: The CSS @page style for the label template. This is used to be inserted at the top of the style block for a given label
"""
width: float
height: float
page_style: Optional[str]
class ReportContextExtension(TypedDict):
"""Report context extension.
Attributes:
page_size: The page size of the report
landscape: Boolean value, True if the report is in landscape mode
"""
page_size: str
landscape: bool
class ReportTemplateBase(MetadataMixin, InvenTree.models.InvenTreeModel): class ReportTemplateBase(MetadataMixin, InvenTree.models.InvenTreeModel):
"""Base class for reports, labels.""" """Base class for reports, labels."""
@ -245,7 +298,7 @@ class ReportTemplateBase(MetadataMixin, InvenTree.models.InvenTreeModel):
"""Return a filter dict which can be applied to the target model.""" """Return a filter dict which can be applied to the target model."""
return report.validators.validate_filters(self.filters, model=self.get_model()) return report.validators.validate_filters(self.filters, model=self.get_model())
def base_context(self, request=None): def base_context(self, request=None) -> BaseContextExtension:
"""Return base context data (available to all templates).""" """Return base context data (available to all templates)."""
return { return {
'base_url': get_base_url(request=request), 'base_url': get_base_url(request=request),
@ -313,11 +366,11 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
help_text=_('Render report in landscape orientation'), help_text=_('Render report in landscape orientation'),
) )
def get_report_size(self): def get_report_size(self) -> str:
"""Return the printable page size for this report.""" """Return the printable page size for this report."""
try: try:
page_size_default = get_global_setting( page_size_default = cast(
'REPORT_DEFAULT_PAGE_SIZE', 'A4', create=False str, get_global_setting('REPORT_DEFAULT_PAGE_SIZE', 'A4', create=False)
) )
except Exception: except Exception:
page_size_default = 'A4' page_size_default = 'A4'
@ -331,12 +384,14 @@ class ReportTemplate(TemplateUploadMixin, ReportTemplateBase):
def get_context(self, instance, request=None, **kwargs): def get_context(self, instance, request=None, **kwargs):
"""Supply context data to the report template for rendering.""" """Supply context data to the report template for rendering."""
context = { base_context = super().get_context(instance, request)
**super().get_context(instance, request), report_context: ReportContextExtension = {
'page_size': self.get_report_size(), 'page_size': self.get_report_size(),
'landscape': self.landscape, 'landscape': self.landscape,
} }
context = {**base_context, **report_context}
# Pass the context through to the plugin registry for any additional information # Pass the context through to the plugin registry for any additional information
for plugin in registry.with_mixin(PluginMixinEnum.REPORT): for plugin in registry.with_mixin(PluginMixinEnum.REPORT):
try: try:
@ -536,12 +591,15 @@ class LabelTemplate(TemplateUploadMixin, ReportTemplateBase):
def get_context(self, instance, request=None, **kwargs): def get_context(self, instance, request=None, **kwargs):
"""Supply context data to the label template for rendering.""" """Supply context data to the label template for rendering."""
context = { base_context = super().get_context(instance, request, **kwargs)
**super().get_context(instance, request, **kwargs), label_context: LabelContextExtension = {
'width': self.width, 'width': self.width,
'height': self.height, 'height': self.height,
'page_style': None,
} }
context = {**base_context, **label_context}
if kwargs.pop('insert_page_style', True): if kwargs.pop('insert_page_style', True):
context['page_style'] = self.generate_page_style() context['page_style'] = self.generate_page_style()

View File

@ -5,6 +5,7 @@ from __future__ import annotations
import os import os
from datetime import timedelta from datetime import timedelta
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from typing import Optional
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -114,6 +115,24 @@ class StockLocationManager(TreeManager):
return super().get_queryset() return super().get_queryset()
class StockLocationReportContext(report.mixins.BaseReportContext):
"""Report context for the StockLocation model.
Attributes:
location: The StockLocation object itself
qr_data: Formatted QR code data for the StockLocation
parent: The parent StockLocation object
stock_location: The StockLocation object itself (shadow of 'location')
stock_items: Query set of all StockItem objects which are located in the StockLocation
"""
location: StockLocation
qr_data: str
parent: Optional[StockLocation]
stock_location: StockLocation
stock_items: report.mixins.QuerySet[StockItem]
class StockLocation( class StockLocation(
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeBarcodeMixin,
report.mixins.InvenTreeReportMixin, report.mixins.InvenTreeReportMixin,
@ -159,7 +178,7 @@ class StockLocation(
"""Return the associated barcode model type code for this model.""" """Return the associated barcode model type code for this model."""
return 'SL' return 'SL'
def report_context(self): def report_context(self) -> StockLocationReportContext:
"""Return report context data for this StockLocation.""" """Return report context data for this StockLocation."""
return { return {
'location': self, 'location': self,
@ -338,6 +357,56 @@ def default_delete_on_deplete():
return True return True
class StockItemReportContext(report.mixins.BaseReportContext):
"""Report context for the StockItem model.
Attributes:
barcode_data: Generated barcode data for the StockItem
barcode_hash: Hash of the barcode data
batch: The batch code for the StockItem
child_items: Query set of all StockItem objects which are children of this StockItem
ipn: The IPN (internal part number) of the associated Part
installed_items: Query set of all StockItem objects which are installed in this StockItem
item: The StockItem object itself
name: The name of the associated Part
part: The Part object which is associated with the StockItem
qr_data: Generated QR code data for the StockItem
qr_url: Generated URL for embedding in a QR code
parameters: Dict object containing the parameters associated with the base Part
quantity: The quantity of the StockItem
result_list: FLattened list of TestResult data associated with the stock item
results: Dict object of TestResult data associated with the StockItem
serial: The serial number of the StockItem
stock_item: The StockItem object itself (shadow of 'item')
tests: Dict object of TestResult data associated with the StockItem (shadow of 'results')
test_keys: List of test keys associated with the StockItem
test_template_list: List of test templates associated with the StockItem
test_templates: Dict object of test templates associated with the StockItem
"""
barcode_data: str
barcode_hash: str
batch: str
child_items: report.mixins.QuerySet[StockItem]
ipn: Optional[str]
installed_items: set[StockItem]
item: StockItem
name: str
part: PartModels.Part
qr_data: str
qr_url: str
parameters: dict[str, str]
quantity: Decimal
result_list: list[StockItemTestResult]
results: dict[str, StockItemTestResult]
serial: Optional[str]
stock_item: StockItem
tests: dict[str, StockItemTestResult]
test_keys: list[str]
test_template_list: report.mixins.QuerySet[PartModels.PartTestTemplate]
test_templates: dict[str, PartModels.PartTestTemplate]
class StockItem( class StockItem(
InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeBarcodeMixin,
@ -415,7 +484,7 @@ class StockItem(
return list(keys) return list(keys)
def report_context(self): def report_context(self) -> StockItemReportContext:
"""Generate custom report context data for this StockItem.""" """Generate custom report context data for this StockItem."""
return { return {
'barcode_data': self.barcode_data, 'barcode_data': self.barcode_data,

View File

@ -1275,6 +1275,7 @@ def export_definitions(c, basedir: str = ''):
Path(basedir + 'inventree_settings.json').resolve(), Path(basedir + 'inventree_settings.json').resolve(),
Path(basedir + 'inventree_tags.yml').resolve(), Path(basedir + 'inventree_tags.yml').resolve(),
Path(basedir + 'inventree_filters.yml').resolve(), Path(basedir + 'inventree_filters.yml').resolve(),
Path(basedir + 'inventree_report_context.json').resolve(),
] ]
info('Exporting definitions...') info('Exporting definitions...')
@ -1286,6 +1287,9 @@ def export_definitions(c, basedir: str = ''):
check_file_existence(filenames[2], overwrite=True) check_file_existence(filenames[2], overwrite=True)
manage(c, f'export_filters {filenames[2]}', pty=True) manage(c, f'export_filters {filenames[2]}', pty=True)
check_file_existence(filenames[3], overwrite=True)
manage(c, f'export_report_context {filenames[3]}', pty=True)
info('Exporting definitions complete') info('Exporting definitions complete')