[docs] Report discovery (#12449)

* Adds decorator to mark an attribute for report context discovery

* Update context variables in docs

* Include fields captured against each model type

* Expose more properties

* Image URL tags

* Add more attributes

* Rearrange docs

* Simplify docs

* Enforce type hinting for reportable properties

* Expose more properties for the Order model

* Auto-discover related / linked models

* Remove extra text

* Collapsible blocks

* Reduce boilerplate

* Remove cruft

* Enhance documentation for filename generation

* Cleanup
This commit is contained in:
Oliver
2026-07-23 21:43:55 +10:00
committed by GitHub
parent 346d7e0a70
commit 65efa51524
13 changed files with 397 additions and 263 deletions
@@ -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:
+23 -5
View File
@@ -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()
+9 -5
View File
@@ -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)
+11 -7
View File
@@ -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 (
+25 -24
View File
@@ -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,
+35 -1
View File
@@ -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.
+5 -2
View File
@@ -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)