mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-28 01:17:21 +00:00
[docs] Report discovery (#12449)
* Adds decorator to mark an attribute for report context discovery * Update context variables in docs * Include fields captured against each model type * Expose more properties * Image URL tags * Add more attributes * Rearrange docs * Simplify docs * Enforce type hinting for reportable properties * Expose more properties for the Order model * Auto-discover related / linked models * Remove extra text * Collapsible blocks * Reduce boilerplate * Remove cruft * Enhance documentation for filename generation * Cleanup
This commit is contained in:
@@ -7,13 +7,14 @@ This in turn allows report context to be documented in the InvenTree documentati
|
||||
without having to manually duplicate the information in multiple places.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import json
|
||||
from typing import get_args, get_origin, get_type_hints
|
||||
|
||||
import django.db.models
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from report.mixins import QuerySet
|
||||
from report.mixins import REPORT_ATTRIBUTE_MARKER, QuerySet
|
||||
|
||||
|
||||
def get_type_str(type_obj):
|
||||
@@ -38,6 +39,29 @@ def get_type_str(type_obj):
|
||||
return f'{type_obj.__module__}.{type_obj.__name__}'
|
||||
|
||||
|
||||
def find_related_models(type_obj) -> set:
|
||||
"""Recursively find any Django Model subclasses referenced by a type annotation.
|
||||
|
||||
Handles plain model classes, `Optional[Model]`, and `report.mixins.QuerySet[Model]`
|
||||
(and any nesting thereof), so that models referenced from a field/property type
|
||||
can be discovered for the "related model" documentation, without needing to be
|
||||
manually curated.
|
||||
"""
|
||||
found = set()
|
||||
|
||||
if type_obj is None or type_obj is type(None):
|
||||
return found
|
||||
|
||||
if inspect.isclass(type_obj) and issubclass(type_obj, django.db.models.Model):
|
||||
found.add(type_obj)
|
||||
return found
|
||||
|
||||
for arg in get_args(type_obj):
|
||||
found |= find_related_models(arg)
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def parse_docstring(docstring: str):
|
||||
"""Parse the docstring of a type object and return a dictionary of sections."""
|
||||
sections = {}
|
||||
@@ -59,6 +83,92 @@ def parse_docstring(docstring: str):
|
||||
return sections
|
||||
|
||||
|
||||
def get_report_attributes(model):
|
||||
"""Find all properties/methods on a model marked with @report_attribute.
|
||||
|
||||
This walks the full MRO of the model class, so attributes contributed by
|
||||
mixins (e.g. `InvenTreeBarcodeMixin.barcode`) are discovered too, regardless
|
||||
of whether they are also explicitly included in `report_context()`.
|
||||
|
||||
Returns a tuple of:
|
||||
|
||||
- A dict of {name: {"description": ..., "type": ...}}, ordered so that
|
||||
attributes redefined further down the MRO (i.e. on the model itself) take
|
||||
precedence over the same name defined on a base/mixin class.
|
||||
- A list of qualified names (e.g. "Part.default_supplier") for any
|
||||
@report_attribute-marked property/method which is missing a return type
|
||||
annotation.
|
||||
- A set of any Django Model subclasses referenced by the return type of a
|
||||
discovered property (e.g. `SupplierPart` for `Part.default_supplier`), used
|
||||
to auto-discover "related" models which are not themselves reportable.
|
||||
"""
|
||||
found = {}
|
||||
errors = []
|
||||
related_models = set()
|
||||
|
||||
for klass in reversed(model.__mro__):
|
||||
for name, member in vars(klass).items():
|
||||
target = member.fget if isinstance(member, property) else member
|
||||
description = getattr(target, REPORT_ATTRIBUTE_MARKER, None)
|
||||
|
||||
if description is None:
|
||||
continue
|
||||
|
||||
return_type = get_type_hints(target).get('return', None)
|
||||
|
||||
if return_type is None:
|
||||
errors.append(f'{klass.__name__}.{name}')
|
||||
else:
|
||||
related_models |= find_related_models(return_type)
|
||||
|
||||
found[name] = {
|
||||
'description': description,
|
||||
'type': get_type_str(return_type) if return_type is not None else '',
|
||||
}
|
||||
|
||||
return found, errors, related_models
|
||||
|
||||
|
||||
def get_model_field_attributes(model):
|
||||
"""Find all concrete database fields defined on a model.
|
||||
|
||||
This includes fields inherited from mixins/abstract base classes, so (for
|
||||
example) `barcode_data` and `barcode_hash` are discovered for any model
|
||||
which uses `InvenTreeBarcodeMixin`, without needing to be documented by hand.
|
||||
|
||||
Reverse relations (e.g. the 'lines' accessor on a linked order) are excluded,
|
||||
as they are not actual fields on this model's table but related querysets -
|
||||
these are documented (where relevant) via `report_context()` or `@report_attribute` instead.
|
||||
|
||||
Returns a tuple of:
|
||||
|
||||
- A dict of {name: {"description": ..., "type": ...}}.
|
||||
- A set of any Django Model subclasses referenced by a relation field (e.g.
|
||||
`PartCategory` for `Part.category`), used to auto-discover "related" models
|
||||
which are not themselves reportable.
|
||||
"""
|
||||
found = {}
|
||||
related_models = set()
|
||||
|
||||
for field in model._meta.get_fields():
|
||||
if not field.concrete:
|
||||
continue
|
||||
|
||||
description = str(getattr(field, 'help_text', '') or '') or str(
|
||||
getattr(field, 'verbose_name', field.name)
|
||||
)
|
||||
|
||||
type_str = type(field).__name__
|
||||
|
||||
if field.is_relation and field.related_model is not None:
|
||||
type_str = f'{type_str}[{get_type_str(field.related_model)}]'
|
||||
related_models.add(field.related_model)
|
||||
|
||||
found[field.name] = {'description': description, 'type': type_str}
|
||||
|
||||
return found, related_models
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Extract report context information, and export to a JSON file."""
|
||||
|
||||
@@ -79,8 +189,10 @@ class Command(BaseCommand):
|
||||
ReportContextExtension,
|
||||
)
|
||||
|
||||
context = {'models': {}, 'base': {}}
|
||||
context = {'models': {}, 'related_models': {}, 'base': {}}
|
||||
is_error = False
|
||||
is_attribute_error = False
|
||||
related_model_classes = set()
|
||||
|
||||
# Base context models
|
||||
for key, model in [
|
||||
@@ -111,10 +223,23 @@ class Command(BaseCommand):
|
||||
is_error = True
|
||||
continue
|
||||
|
||||
fields, field_related = get_model_field_attributes(model)
|
||||
properties, property_errors, property_related = get_report_attributes(model)
|
||||
|
||||
related_model_classes |= field_related | property_related
|
||||
|
||||
for qualified_name in property_errors:
|
||||
print(
|
||||
f'Error: {qualified_name} is marked with @report_attribute but does not have a return type annotation'
|
||||
)
|
||||
is_attribute_error = True
|
||||
|
||||
context['models'][model_key] = {
|
||||
'key': model_key,
|
||||
'name': model_name,
|
||||
'context': {},
|
||||
'fields': fields,
|
||||
'properties': properties,
|
||||
}
|
||||
|
||||
attributes = parse_docstring(ctx_type.__doc__).get('Attributes', {})
|
||||
@@ -129,6 +254,38 @@ class Command(BaseCommand):
|
||||
'INVE-E4 - Some models associated with the `InvenTreeReportMixin` do not have a valid `report_context` return type annotation.'
|
||||
)
|
||||
|
||||
# Related models: models which are not themselves reportable, but which are
|
||||
# referenced by a field or @report_attribute property on a reportable model.
|
||||
# Discovered automatically (one hop) from the fields/properties collected above,
|
||||
# so no manual list of "related" models needs to be maintained.
|
||||
reportable_models = set(report_model_types())
|
||||
|
||||
for model in sorted(
|
||||
related_model_classes - reportable_models, key=lambda m: m.__name__
|
||||
):
|
||||
model_key = model.__name__.lower()
|
||||
|
||||
fields, _ = get_model_field_attributes(model)
|
||||
properties, property_errors, _ = get_report_attributes(model)
|
||||
|
||||
for qualified_name in property_errors:
|
||||
print(
|
||||
f'Error: {qualified_name} is marked with @report_attribute but does not have a return type annotation'
|
||||
)
|
||||
is_attribute_error = True
|
||||
|
||||
context['related_models'][model_key] = {
|
||||
'key': model_key,
|
||||
'name': str(model._meta.verbose_name).title(),
|
||||
'fields': fields,
|
||||
'properties': properties,
|
||||
}
|
||||
|
||||
if is_attribute_error:
|
||||
raise CommandError(
|
||||
'INVE-E5 - Some properties/methods marked with `@report_attribute` do not have a valid return type annotation.'
|
||||
)
|
||||
|
||||
filename = kwargs.get('filename', 'inventree_report_context.json')
|
||||
|
||||
with open(filename, 'w', encoding='utf-8') as f:
|
||||
|
||||
@@ -34,6 +34,7 @@ import InvenTree.format
|
||||
import InvenTree.helpers
|
||||
import InvenTree.helpers_model
|
||||
import InvenTree.sentry
|
||||
import report.mixins
|
||||
|
||||
logger = structlog.get_logger('inventree')
|
||||
|
||||
@@ -559,7 +560,8 @@ class InvenTreeParameterMixin(InvenTreePermissionCheckMixin, models.Model):
|
||||
)
|
||||
|
||||
@property
|
||||
def parameters(self) -> QuerySet:
|
||||
@report.mixins.report_attribute()
|
||||
def parameters(self) -> report.mixins.QuerySet:
|
||||
"""Return a QuerySet containing all the Parameter instances for this model.
|
||||
|
||||
This will return pre-fetched data if available (i.e. in a serializer context).
|
||||
@@ -692,7 +694,8 @@ class InvenTreeAttachmentMixin(InvenTreePermissionCheckMixin):
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def attachments(self) -> QuerySet:
|
||||
@report.mixins.report_attribute()
|
||||
def attachments(self) -> report.mixins.QuerySet:
|
||||
"""Return a queryset containing all attachments for this model."""
|
||||
return self.attachments_for_model().filter(model_id=self.pk)
|
||||
|
||||
@@ -1269,8 +1272,9 @@ class PathStringMixin(models.Model):
|
||||
)
|
||||
|
||||
@property
|
||||
@report.mixins.report_attribute()
|
||||
def parentpath(self) -> list:
|
||||
"""Get the parent path of this category.
|
||||
"""Construct the parent path of this tree node.
|
||||
|
||||
Returns:
|
||||
List of category names from the top level to the parent of this category
|
||||
@@ -1278,8 +1282,9 @@ class PathStringMixin(models.Model):
|
||||
return list(self.get_ancestors())
|
||||
|
||||
@property
|
||||
@report.mixins.report_attribute()
|
||||
def path(self) -> list:
|
||||
"""Get the complete part of this category.
|
||||
"""Construct the complete part of this tree node.
|
||||
|
||||
e.g. ["Top", "Second", "Third", "This"]
|
||||
|
||||
@@ -1468,6 +1473,7 @@ class InvenTreeBarcodeMixin(models.Model):
|
||||
return data
|
||||
|
||||
@property
|
||||
@report.mixins.report_attribute()
|
||||
def barcode(self) -> str:
|
||||
"""Format a minimal barcode string (e.g. for label printing)."""
|
||||
return self.format_barcode()
|
||||
@@ -1646,14 +1652,26 @@ class InvenTreeImageMixin(models.Model):
|
||||
verbose_name=_('Image'),
|
||||
)
|
||||
|
||||
def get_image_url(self):
|
||||
def get_image_url(self) -> str:
|
||||
"""Return the URL of the image for this object."""
|
||||
if self.image:
|
||||
return InvenTree.helpers.getMediaUrl(self.image)
|
||||
return InvenTree.helpers.getBlankImage()
|
||||
|
||||
@property
|
||||
@report.mixins.report_attribute()
|
||||
def image_url(self) -> str:
|
||||
"""Return the URL of the image for this object."""
|
||||
return self.get_image_url()
|
||||
|
||||
def get_thumbnail_url(self) -> str:
|
||||
"""Return the URL of the image thumbnail for this object."""
|
||||
if self.image:
|
||||
return InvenTree.helpers.getMediaUrl(self.image, 'thumbnail')
|
||||
return InvenTree.helpers.getBlankThumbnail()
|
||||
|
||||
@property
|
||||
@report.mixins.report_attribute()
|
||||
def thumbnail_url(self) -> str:
|
||||
"""Return the URL of the image thumbnail for this object."""
|
||||
return self.get_thumbnail_url()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import os
|
||||
from decimal import Decimal
|
||||
from typing import TypedDict
|
||||
from typing import Optional, TypedDict
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
@@ -229,7 +229,8 @@ class Company(
|
||||
)
|
||||
|
||||
@property
|
||||
def address(self):
|
||||
@report.mixins.report_attribute()
|
||||
def address(self) -> Optional[str]:
|
||||
"""Return the string representation for the primary address.
|
||||
|
||||
This property exists for backwards compatibility
|
||||
@@ -239,7 +240,8 @@ class Company(
|
||||
return str(addr) if addr is not None else None
|
||||
|
||||
@property
|
||||
def primary_address(self):
|
||||
@report.mixins.report_attribute()
|
||||
def primary_address(self) -> Optional['Address']:
|
||||
"""Returns address object of primary address for this Company."""
|
||||
# We may have a pre-fetched primary address list
|
||||
if hasattr(self, 'primary_address_list'):
|
||||
@@ -254,7 +256,8 @@ class Company(
|
||||
return self.addresses.filter(primary=True).first()
|
||||
|
||||
@property
|
||||
def currency_code(self):
|
||||
@report.mixins.report_attribute()
|
||||
def currency_code(self) -> str:
|
||||
"""Return the currency code associated with this company.
|
||||
|
||||
- If the currency code is invalid, use the default currency
|
||||
@@ -276,7 +279,8 @@ class Company(
|
||||
return InvenTree.helpers.pui_url(f'/purchasing/manufacturer/{self.id}')
|
||||
|
||||
@property
|
||||
def parts(self):
|
||||
@report.mixins.report_attribute()
|
||||
def parts(self) -> report.mixins.QuerySet['SupplierPart']:
|
||||
"""Return SupplierPart objects which are supplied or manufactured by this company."""
|
||||
return SupplierPart.objects.filter(
|
||||
Q(supplier=self.id) | Q(manufacturer_part__manufacturer=self.id)
|
||||
|
||||
@@ -478,8 +478,9 @@ class Order(
|
||||
)
|
||||
|
||||
@property
|
||||
def is_overdue(self):
|
||||
"""Method to determine if this order is overdue.
|
||||
@report.mixins.report_attribute()
|
||||
def is_overdue(self) -> bool:
|
||||
"""Determine if this order is overdue.
|
||||
|
||||
Makes use of the overdue_filter() method to avoid code duplication
|
||||
"""
|
||||
@@ -587,20 +588,23 @@ class Order(
|
||||
)
|
||||
|
||||
@property
|
||||
def company(self):
|
||||
"""Return the company associated with this order.
|
||||
@report.mixins.report_attribute()
|
||||
def company(self) -> Optional[Company]:
|
||||
"""The company associated with this order.
|
||||
|
||||
This method must be implemented by any subclass, as the 'company' field may be named differently for different order types (e.g. supplier vs customer).
|
||||
"""
|
||||
raise NotImplementedError(f'company() method not implemented for {__class__}')
|
||||
|
||||
@property
|
||||
def order_address(self):
|
||||
"""Return the Address associated with this order."""
|
||||
@report.mixins.report_attribute()
|
||||
def order_address(self) -> Optional[Address]:
|
||||
"""The Address associated with this order."""
|
||||
return self.address or self.company.primary_address
|
||||
|
||||
@property
|
||||
def status_text(self):
|
||||
@report.mixins.report_attribute()
|
||||
def status_text(self) -> str:
|
||||
"""Return the text representation of the current status. This will consider any custom status."""
|
||||
if self.get_custom_status() is not None:
|
||||
from generic.states.custom import (
|
||||
|
||||
@@ -9,7 +9,7 @@ import os
|
||||
import re
|
||||
from datetime import timedelta
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
from typing import TypedDict, cast
|
||||
from typing import Optional, TypedDict, cast
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
@@ -35,6 +35,7 @@ from mptt.models import TreeForeignKey
|
||||
|
||||
import common.currency
|
||||
import common.models
|
||||
import company.models
|
||||
import InvenTree.conversion
|
||||
import InvenTree.fields
|
||||
import InvenTree.helpers
|
||||
@@ -1003,6 +1004,7 @@ class Part(
|
||||
return InvenTree.helpers.increment_serial_number(sn, self)
|
||||
|
||||
@property
|
||||
@report.mixins.report_attribute()
|
||||
def full_name(self) -> str:
|
||||
"""Format a 'full name' for this Part based on the format PART_NAME_FORMAT defined in InvenTree settings."""
|
||||
return part_helpers.render_part_full_name(self)
|
||||
@@ -1214,7 +1216,8 @@ class Part(
|
||||
return None
|
||||
|
||||
@property
|
||||
def default_supplier(self):
|
||||
@report.mixins.report_attribute()
|
||||
def default_supplier(self) -> Optional[company.models.SupplierPart]:
|
||||
"""Return the default (primary) SupplierPart for this Part.
|
||||
|
||||
This function is included for backwards compatibility,
|
||||
@@ -1369,14 +1372,15 @@ class Part(
|
||||
)
|
||||
|
||||
@property
|
||||
def category_path(self):
|
||||
def category_path(self) -> str:
|
||||
"""Return the category path of this Part instance."""
|
||||
if self.category:
|
||||
return self.category.pathstring
|
||||
return ''
|
||||
|
||||
@property
|
||||
def available_stock(self):
|
||||
@report.mixins.report_attribute()
|
||||
def available_stock(self) -> Decimal:
|
||||
"""Return the total available stock.
|
||||
|
||||
- This subtracts stock which is already allocated to builds
|
||||
@@ -1614,7 +1618,8 @@ class Part(
|
||||
PartStar.objects.filter(part=self, user=user).delete()
|
||||
|
||||
@property
|
||||
def can_build(self):
|
||||
@report.mixins.report_attribute()
|
||||
def can_build(self) -> Decimal:
|
||||
"""Return the number of units that can be build with available stock."""
|
||||
import part.filters
|
||||
|
||||
@@ -1646,7 +1651,7 @@ class Part(
|
||||
return int(max(can_build_quantity, 0))
|
||||
|
||||
@property
|
||||
def active_builds(self):
|
||||
def active_builds(self) -> int:
|
||||
"""Return a list of outstanding builds.
|
||||
|
||||
Builds marked as 'complete' or 'cancelled' are ignored
|
||||
@@ -1654,12 +1659,10 @@ class Part(
|
||||
return self.builds.filter(status__in=BuildStatusGroups.ACTIVE_CODES)
|
||||
|
||||
@property
|
||||
def quantity_being_built(self, include_variants: bool = True):
|
||||
@report.mixins.report_attribute()
|
||||
def quantity_being_built(self) -> Decimal:
|
||||
"""Return the current number of parts currently being built.
|
||||
|
||||
Arguments:
|
||||
include_variants: If True, include variants of this part in the calculation
|
||||
|
||||
Note: This is the total quantity of Build orders, *not* the number of build outputs.
|
||||
In this fashion, it is the "projected" quantity of builds
|
||||
"""
|
||||
@@ -1667,12 +1670,8 @@ class Part(
|
||||
status__in=BuildStatusGroups.ACTIVE_CODES
|
||||
)
|
||||
|
||||
if include_variants:
|
||||
# If we are including variants, get all parts in the variant tree
|
||||
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,
|
||||
|
||||
@@ -1,11 +1,45 @@
|
||||
"""Report mixin classes."""
|
||||
|
||||
from typing import Generic, TypedDict, TypeVar
|
||||
from collections.abc import Callable
|
||||
from typing import Generic, Optional, TypedDict, TypeVar
|
||||
|
||||
from django.db import models
|
||||
|
||||
_Model = TypeVar('_Model', bound=models.Model, covariant=True)
|
||||
|
||||
REPORT_ATTRIBUTE_MARKER = '_report_attribute_description'
|
||||
|
||||
|
||||
def report_attribute(description: Optional[str] = None) -> Callable:
|
||||
"""Mark a model property or method as discoverable for report documentation.
|
||||
|
||||
Unlike `InvenTreeReportMixin.report_context`, which only documents context data
|
||||
explicitly assembled for a particular model, this decorator can be applied to any
|
||||
property or method (including those defined on mixins shared by multiple models)
|
||||
to make it show up in the generated report context documentation.
|
||||
|
||||
If no description is provided, the first line of the decorated function's
|
||||
docstring is used instead.
|
||||
|
||||
When decorating a `@property`, this decorator must be applied to the underlying
|
||||
function, i.e. *below* the `@property` decorator:
|
||||
|
||||
Example:
|
||||
```python
|
||||
@property
|
||||
@report_attribute(description="Format a minimal barcode string")
|
||||
def barcode(self) -> str:
|
||||
...
|
||||
```
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
doc = (func.__doc__ or '').strip().splitlines()
|
||||
setattr(func, REPORT_ATTRIBUTE_MARKER, description or (doc[0] if doc else ''))
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class QuerySet(Generic[_Model]):
|
||||
"""A custom QuerySet class used for type hinting in report context definitions.
|
||||
|
||||
@@ -227,6 +227,7 @@ class StockLocation(
|
||||
)
|
||||
|
||||
@property
|
||||
@report.mixins.report_attribute()
|
||||
def icon(self) -> str:
|
||||
"""Get the current icon used for this location.
|
||||
|
||||
@@ -325,7 +326,8 @@ class StockLocation(
|
||||
return self.get_stock_items(cascade).count()
|
||||
|
||||
@property
|
||||
def item_count(self):
|
||||
@report.mixins.report_attribute()
|
||||
def item_count(self) -> int:
|
||||
"""Simply returns the number of stock items in this location.
|
||||
|
||||
Required for tree view serializer.
|
||||
@@ -865,7 +867,8 @@ class StockItem(
|
||||
return self.get_next_serialized_item(reverse=True)
|
||||
|
||||
@property
|
||||
def status_label(self):
|
||||
@report.mixins.report_attribute()
|
||||
def status_label(self) -> str:
|
||||
"""Return label."""
|
||||
return StockStatus.label(self.status)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user