From 65efa51524d2b1ae4a384b91f5096b6c9fcd10a0 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 23 Jul 2026 21:43:55 +1000 Subject: [PATCH] [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 --- docs/docs/barcodes/custom.md | 2 +- docs/docs/report/context_variables.md | 234 ++---------------- docs/docs/report/index.md | 15 +- docs/docs/report/model_context.md | 24 ++ docs/main.py | 69 ++++++ docs/mkdocs.yml | 3 +- .../commands/export_report_context.py | 161 +++++++++++- src/backend/InvenTree/InvenTree/models.py | 28 ++- src/backend/InvenTree/company/models.py | 14 +- src/backend/InvenTree/order/models.py | 18 +- src/backend/InvenTree/part/models.py | 49 ++-- src/backend/InvenTree/report/mixins.py | 36 ++- src/backend/InvenTree/stock/models.py | 7 +- 13 files changed, 397 insertions(+), 263 deletions(-) create mode 100644 docs/docs/report/model_context.md diff --git a/docs/docs/barcodes/custom.md b/docs/docs/barcodes/custom.md index 560f142742..daf7b48967 100644 --- a/docs/docs/barcodes/custom.md +++ b/docs/docs/barcodes/custom.md @@ -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: diff --git a/docs/docs/report/context_variables.md b/docs/docs/report/context_variables.md index cead76c138..5d13e225c3 100644 --- a/docs/docs/report/context_variables.md +++ b/docs/docs/report/context_variables.md @@ -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. diff --git a/docs/docs/report/index.md b/docs/docs/report/index.md index 7b3d3efe7f..0d6e3cba3f 100644 --- a/docs/docs/report/index.md +++ b/docs/docs/report/index.md @@ -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 diff --git a/docs/docs/report/model_context.md b/docs/docs/report/model_context.md new file mode 100644 index 0000000000..01bf2ccd51 --- /dev/null +++ b/docs/docs/report/model_context.md @@ -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() }} diff --git a/docs/main.py b/docs/main.py index 148e61be65..9e44c5e8b1 100644 --- a/docs/main.py +++ b/docs/main.py @@ -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, diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 0eed940e1a..833abd5f90 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -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 diff --git a/src/backend/InvenTree/InvenTree/management/commands/export_report_context.py b/src/backend/InvenTree/InvenTree/management/commands/export_report_context.py index cbebae51c4..2a0ff28056 100644 --- a/src/backend/InvenTree/InvenTree/management/commands/export_report_context.py +++ b/src/backend/InvenTree/InvenTree/management/commands/export_report_context.py @@ -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: diff --git a/src/backend/InvenTree/InvenTree/models.py b/src/backend/InvenTree/InvenTree/models.py index 769ad0bdd5..81950a5c99 100644 --- a/src/backend/InvenTree/InvenTree/models.py +++ b/src/backend/InvenTree/InvenTree/models.py @@ -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() diff --git a/src/backend/InvenTree/company/models.py b/src/backend/InvenTree/company/models.py index 29af645fbf..6e126f558b 100644 --- a/src/backend/InvenTree/company/models.py +++ b/src/backend/InvenTree/company/models.py @@ -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) diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index ea16288819..42419c0c16 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -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 ( diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 19dd3f806c..f16b7e97f0 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -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 - builds = builds.filter(part__in=self.get_descendants(include_self=True)) - else: - # Only look at this part - builds = builds.filter(part=self) + # We are including variants, get all parts in the variant tree + builds = builds.filter(part__in=self.get_descendants(include_self=True)) 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, diff --git a/src/backend/InvenTree/report/mixins.py b/src/backend/InvenTree/report/mixins.py index d7b829ef4a..9c0d49a945 100644 --- a/src/backend/InvenTree/report/mixins.py +++ b/src/backend/InvenTree/report/mixins.py @@ -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. diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 2ec8a9f592..1358bc160e 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -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)