inventree-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. inventree_mcp-0.1.0/LICENSE +21 -0
  2. inventree_mcp-0.1.0/MANIFEST.in +1 -0
  3. inventree_mcp-0.1.0/PKG-INFO +123 -0
  4. inventree_mcp-0.1.0/README.md +104 -0
  5. inventree_mcp-0.1.0/inventree_mcp/__init__.py +1 -0
  6. inventree_mcp-0.1.0/inventree_mcp/context.py +78 -0
  7. inventree_mcp-0.1.0/inventree_mcp/core.py +59 -0
  8. inventree_mcp-0.1.0/inventree_mcp/expand_introspection.py +49 -0
  9. inventree_mcp-0.1.0/inventree_mcp/filter_introspection.py +189 -0
  10. inventree_mcp-0.1.0/inventree_mcp/mcp_server.py +57 -0
  11. inventree_mcp-0.1.0/inventree_mcp/mcp_transport.py +219 -0
  12. inventree_mcp-0.1.0/inventree_mcp/oauth2_bridge.py +116 -0
  13. inventree_mcp-0.1.0/inventree_mcp/output_schemas.py +141 -0
  14. inventree_mcp-0.1.0/inventree_mcp/proxy.py +231 -0
  15. inventree_mcp-0.1.0/inventree_mcp/schema_introspection.py +163 -0
  16. inventree_mcp-0.1.0/inventree_mcp/settings.py +36 -0
  17. inventree_mcp-0.1.0/inventree_mcp/test_mcp.py +2138 -0
  18. inventree_mcp-0.1.0/inventree_mcp/tool_logging.py +136 -0
  19. inventree_mcp-0.1.0/inventree_mcp/tool_visibility.py +183 -0
  20. inventree_mcp-0.1.0/inventree_mcp/tools/__init__.py +5 -0
  21. inventree_mcp-0.1.0/inventree_mcp/tools/_common.py +44 -0
  22. inventree_mcp-0.1.0/inventree_mcp/tools/attachments.py +117 -0
  23. inventree_mcp-0.1.0/inventree_mcp/tools/bom.py +207 -0
  24. inventree_mcp-0.1.0/inventree_mcp/tools/build_orders.py +258 -0
  25. inventree_mcp-0.1.0/inventree_mcp/tools/categories.py +88 -0
  26. inventree_mcp-0.1.0/inventree_mcp/tools/companies.py +239 -0
  27. inventree_mcp-0.1.0/inventree_mcp/tools/discovery.py +236 -0
  28. inventree_mcp-0.1.0/inventree_mcp/tools/locations.py +88 -0
  29. inventree_mcp-0.1.0/inventree_mcp/tools/parameters.py +202 -0
  30. inventree_mcp-0.1.0/inventree_mcp/tools/parts.py +93 -0
  31. inventree_mcp-0.1.0/inventree_mcp/tools/project_codes.py +92 -0
  32. inventree_mcp-0.1.0/inventree_mcp/tools/purchase_orders.py +185 -0
  33. inventree_mcp-0.1.0/inventree_mcp/tools/return_orders.py +188 -0
  34. inventree_mcp-0.1.0/inventree_mcp/tools/sales_orders.py +280 -0
  35. inventree_mcp-0.1.0/inventree_mcp/tools/stock.py +97 -0
  36. inventree_mcp-0.1.0/inventree_mcp/tools/stock_history.py +198 -0
  37. inventree_mcp-0.1.0/inventree_mcp/tools/supplier_parts.py +180 -0
  38. inventree_mcp-0.1.0/inventree_mcp/view_resolution.py +65 -0
  39. inventree_mcp-0.1.0/inventree_mcp.egg-info/PKG-INFO +123 -0
  40. inventree_mcp-0.1.0/inventree_mcp.egg-info/SOURCES.txt +45 -0
  41. inventree_mcp-0.1.0/inventree_mcp.egg-info/dependency_links.txt +1 -0
  42. inventree_mcp-0.1.0/inventree_mcp.egg-info/entry_points.txt +2 -0
  43. inventree_mcp-0.1.0/inventree_mcp.egg-info/requires.txt +4 -0
  44. inventree_mcp-0.1.0/inventree_mcp.egg-info/top_level.txt +2 -0
  45. inventree_mcp-0.1.0/pyproject.toml +61 -0
  46. inventree_mcp-0.1.0/setup.cfg +19 -0
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Oliver Walters <admin@inventree.org>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1 @@
1
+ recursive-include inventree_mcp/static *
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: inventree-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for InvenTree
5
+ Author-email: Oliver Walters <admin@inventree.org>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/inventree/inventree-mcp
8
+ Keywords: inventree,plugin
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Framework :: InvenTree
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: mcp<3,>=2.0
16
+ Provides-Extra: test
17
+ Requires-Dist: coverage; extra == "test"
18
+ Dynamic: license-file
19
+
20
+ # InvenTreeMCP
21
+
22
+ [![CI](https://github.com/inventree/inventree-mcp/actions/workflows/ci.yaml/badge.svg)](https://github.com/inventree/inventree-mcp/actions/workflows/ci.yaml)
23
+ [![codecov](https://codecov.io/gh/inventree/inventree-mcp/graph/badge.svg)](https://codecov.io/gh/inventree/inventree-mcp)
24
+
25
+ An MCP (Model Context Protocol) server for InvenTree, exposed as an InvenTree plugin. It lets MCP
26
+ clients (Claude, other MCP-aware agents) query InvenTree inventory data over a Streamable HTTP
27
+ endpoint.
28
+
29
+ ## Design
30
+
31
+ Every tool is a thin wrapper around InvenTree's own REST API view classes (see
32
+ [`inventree_mcp/proxy.py`](inventree_mcp/proxy.py)), dispatched as the authenticated caller - MCP
33
+ requests go through exactly the same permission checks, filtering, and serialization as the regular
34
+ REST API. Tool code never queries the Django ORM directly.
35
+
36
+ Currently read-only, covering parts, stock items/locations, part categories, purchase/sales/return/
37
+ build orders (with line items and allocations), companies, contacts, addresses, manufacturer/
38
+ supplier parts, BOM items, attachments, parameters, stock tracking history, test results, and
39
+ project codes. Once write tools land, the `MCP_READ_ONLY` setting (see Configuration) will block
40
+ them by default regardless of the calling user's permissions.
41
+
42
+ Each tool's `outputSchema` and filter/ordering options are derived live from InvenTree's own
43
+ serializers and views (not hand-maintained), so they can't drift as InvenTree evolves. Call
44
+ `describe_filters(resource)` to see what's available for a given resource. The set of tools an MCP
45
+ client sees is also filtered to what the calling user can actually use - though every call is still
46
+ permission-checked in full regardless of what was advertised.
47
+
48
+ ## Setup
49
+
50
+ ### 1. Install the plugin
51
+
52
+ Install via the InvenTree plugin manager, or via pip:
53
+
54
+ ```bash
55
+ pip install inventree-mcp
56
+ ```
57
+
58
+ Then enable the plugin under **Admin > Plugins**, and configure its settings (see
59
+ Configuration below).
60
+
61
+ ### 2. Create a token for your MCP client
62
+
63
+ Create an InvenTree API token for your MCP client.
64
+
65
+ ### 3. Configure your MCP client
66
+
67
+ The endpoint is `<your-inventree-server>/plugin/inventree-mcp/mcp/`, using Streamable HTTP
68
+ transport with an `Authorization: Token <token>` header.
69
+
70
+ For a client that supports remote Streamable HTTP servers directly, add:
71
+
72
+ ```json
73
+ {
74
+ "mcpServers": {
75
+ "inventree": {
76
+ "url": "https://<your-inventree-server>/plugin/inventree-mcp/mcp/",
77
+ "headers": {
78
+ "Authorization": "Token <your-api-token>"
79
+ }
80
+ }
81
+ }
82
+ }
83
+ ```
84
+
85
+ For a client that only supports local (stdio) servers, bridge it with
86
+ [`mcp-remote`](https://www.npmjs.com/package/mcp-remote):
87
+
88
+ ```json
89
+ {
90
+ "mcpServers": {
91
+ "inventree": {
92
+ "command": "npx",
93
+ "args": [
94
+ "mcp-remote",
95
+ "https://<your-inventree-server>/plugin/inventree-mcp/mcp/",
96
+ "--header",
97
+ "Authorization: Token <your-api-token>"
98
+ ]
99
+ }
100
+ }
101
+ }
102
+ ```
103
+
104
+ ## Configuration
105
+
106
+ Under **Settings > Plugin Settings**:
107
+
108
+ - **Require Authentication** (`REQUIRE_AUTH`, default `True`): reject unauthenticated requests.
109
+ Only disable for local testing.
110
+ - **Read Only** (`MCP_READ_ONLY`, default `True`): block all write actions via MCP, regardless of
111
+ the calling user's permissions. A plugin-wide kill switch, independent of per-user roles.
112
+
113
+ ## Authentication
114
+
115
+ Access follows the calling user's normal InvenTree role assignments. Supported auth methods:
116
+
117
+ - An InvenTree API token: `Authorization: Token <token>`.
118
+ - Basic auth (username/password).
119
+ - An OAuth2 bearer token: `Authorization: Bearer <token>`. A scoped token (e.g. `r:view:part`)
120
+ narrows access *below* the underlying user's roles - useful for issuing an agent a tightly-scoped
121
+ token without creating a separate low-privilege user.
122
+
123
+ Session/cookie auth is not supported (not meaningful for a machine client).
@@ -0,0 +1,104 @@
1
+ # InvenTreeMCP
2
+
3
+ [![CI](https://github.com/inventree/inventree-mcp/actions/workflows/ci.yaml/badge.svg)](https://github.com/inventree/inventree-mcp/actions/workflows/ci.yaml)
4
+ [![codecov](https://codecov.io/gh/inventree/inventree-mcp/graph/badge.svg)](https://codecov.io/gh/inventree/inventree-mcp)
5
+
6
+ An MCP (Model Context Protocol) server for InvenTree, exposed as an InvenTree plugin. It lets MCP
7
+ clients (Claude, other MCP-aware agents) query InvenTree inventory data over a Streamable HTTP
8
+ endpoint.
9
+
10
+ ## Design
11
+
12
+ Every tool is a thin wrapper around InvenTree's own REST API view classes (see
13
+ [`inventree_mcp/proxy.py`](inventree_mcp/proxy.py)), dispatched as the authenticated caller - MCP
14
+ requests go through exactly the same permission checks, filtering, and serialization as the regular
15
+ REST API. Tool code never queries the Django ORM directly.
16
+
17
+ Currently read-only, covering parts, stock items/locations, part categories, purchase/sales/return/
18
+ build orders (with line items and allocations), companies, contacts, addresses, manufacturer/
19
+ supplier parts, BOM items, attachments, parameters, stock tracking history, test results, and
20
+ project codes. Once write tools land, the `MCP_READ_ONLY` setting (see Configuration) will block
21
+ them by default regardless of the calling user's permissions.
22
+
23
+ Each tool's `outputSchema` and filter/ordering options are derived live from InvenTree's own
24
+ serializers and views (not hand-maintained), so they can't drift as InvenTree evolves. Call
25
+ `describe_filters(resource)` to see what's available for a given resource. The set of tools an MCP
26
+ client sees is also filtered to what the calling user can actually use - though every call is still
27
+ permission-checked in full regardless of what was advertised.
28
+
29
+ ## Setup
30
+
31
+ ### 1. Install the plugin
32
+
33
+ Install via the InvenTree plugin manager, or via pip:
34
+
35
+ ```bash
36
+ pip install inventree-mcp
37
+ ```
38
+
39
+ Then enable the plugin under **Admin > Plugins**, and configure its settings (see
40
+ Configuration below).
41
+
42
+ ### 2. Create a token for your MCP client
43
+
44
+ Create an InvenTree API token for your MCP client.
45
+
46
+ ### 3. Configure your MCP client
47
+
48
+ The endpoint is `<your-inventree-server>/plugin/inventree-mcp/mcp/`, using Streamable HTTP
49
+ transport with an `Authorization: Token <token>` header.
50
+
51
+ For a client that supports remote Streamable HTTP servers directly, add:
52
+
53
+ ```json
54
+ {
55
+ "mcpServers": {
56
+ "inventree": {
57
+ "url": "https://<your-inventree-server>/plugin/inventree-mcp/mcp/",
58
+ "headers": {
59
+ "Authorization": "Token <your-api-token>"
60
+ }
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ For a client that only supports local (stdio) servers, bridge it with
67
+ [`mcp-remote`](https://www.npmjs.com/package/mcp-remote):
68
+
69
+ ```json
70
+ {
71
+ "mcpServers": {
72
+ "inventree": {
73
+ "command": "npx",
74
+ "args": [
75
+ "mcp-remote",
76
+ "https://<your-inventree-server>/plugin/inventree-mcp/mcp/",
77
+ "--header",
78
+ "Authorization: Token <your-api-token>"
79
+ ]
80
+ }
81
+ }
82
+ }
83
+ ```
84
+
85
+ ## Configuration
86
+
87
+ Under **Settings > Plugin Settings**:
88
+
89
+ - **Require Authentication** (`REQUIRE_AUTH`, default `True`): reject unauthenticated requests.
90
+ Only disable for local testing.
91
+ - **Read Only** (`MCP_READ_ONLY`, default `True`): block all write actions via MCP, regardless of
92
+ the calling user's permissions. A plugin-wide kill switch, independent of per-user roles.
93
+
94
+ ## Authentication
95
+
96
+ Access follows the calling user's normal InvenTree role assignments. Supported auth methods:
97
+
98
+ - An InvenTree API token: `Authorization: Token <token>`.
99
+ - Basic auth (username/password).
100
+ - An OAuth2 bearer token: `Authorization: Bearer <token>`. A scoped token (e.g. `r:view:part`)
101
+ narrows access *below* the underlying user's roles - useful for issuing an agent a tightly-scoped
102
+ token without creating a separate low-privilege user.
103
+
104
+ Session/cookie auth is not supported (not meaningful for a machine client).
@@ -0,0 +1 @@
1
+ PLUGIN_VERSION = "0.1.0"
@@ -0,0 +1,78 @@
1
+ """Propagate the authenticated Django user (and OAuth2 auth, if any) into MCP tool execution.
2
+
3
+ Tool functions are invoked by the MCP SDK's dispatcher, which does not know
4
+ about Django requests. The transport view sets the current identity via a
5
+ contextvar before handing the request off to the MCP session manager, so
6
+ tools (and the proxy layer) can recover *who* is calling without threading a
7
+ request object through every tool signature.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from contextvars import ContextVar, Token
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ if TYPE_CHECKING:
17
+ from django.contrib.auth.models import AbstractUser
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class _Identity:
22
+ user: AbstractUser
23
+ # The real oauth2_provider AccessToken, if the MCP request itself was
24
+ # OAuth2-authenticated - None for token/basic/session auth. Propagated so
25
+ # proxy.call_view() can make the proxied view see the token's *actual*
26
+ # granted scopes, not just the user's role permissions. See proxy.py.
27
+ oauth2_token: Any | None = None
28
+
29
+
30
+ _current_identity: ContextVar[_Identity | None] = ContextVar(
31
+ "inventree_mcp_current_identity", default=None
32
+ )
33
+
34
+
35
+ def set_current_user(
36
+ user: AbstractUser | None, oauth2_token: Any | None = None
37
+ ) -> Token:
38
+ """Bind *user* (and, if OAuth2-authenticated, the real access token) for this context."""
39
+ identity = _Identity(user, oauth2_token) if user is not None else None
40
+ return _current_identity.set(identity)
41
+
42
+
43
+ def reset_current_user(token: Token) -> None:
44
+ """Undo a prior set_current_user call."""
45
+ _current_identity.reset(token)
46
+
47
+
48
+ def has_current_user() -> bool:
49
+ """Return whether a user is currently bound, without raising if not.
50
+
51
+ For code that needs to behave differently outside a real MCP request
52
+ (e.g. tool_visibility.py, which can't run a permission check without a
53
+ caller to check permissions *for*) rather than treat "no caller" as a
54
+ fatal error the way get_current_user() deliberately does everywhere else.
55
+ """
56
+ identity = _current_identity.get()
57
+ return identity is not None and identity.user.is_authenticated
58
+
59
+
60
+ def get_current_user() -> AbstractUser:
61
+ """Return the user bound to the current MCP request.
62
+
63
+ Raises:
64
+ PermissionError: if no user is bound (e.g. a tool is invoked outside
65
+ of a real MCP request, or authentication was not enforced).
66
+ """
67
+ identity = _current_identity.get()
68
+
69
+ if identity is None or not identity.user.is_authenticated:
70
+ raise PermissionError("No authenticated user for this MCP request")
71
+
72
+ return identity.user
73
+
74
+
75
+ def get_current_oauth2_token() -> Any | None:
76
+ """Return the real OAuth2 access token for the current MCP request, if any."""
77
+ identity = _current_identity.get()
78
+ return identity.oauth2_token if identity else None
@@ -0,0 +1,59 @@
1
+ """MCP server for InvenTree"""
2
+
3
+ from typing import ClassVar
4
+
5
+ from plugin import InvenTreePlugin
6
+ from plugin.mixins import SettingsMixin, UrlsMixin
7
+
8
+ from . import PLUGIN_VERSION
9
+
10
+
11
+ class InvenTreeMCP(SettingsMixin, UrlsMixin, InvenTreePlugin):
12
+ """InvenTreeMCP - custom InvenTree plugin."""
13
+
14
+ # Plugin metadata
15
+ TITLE = "InvenTree MCP"
16
+ NAME = "InvenTreeMCP"
17
+ SLUG = "inventree-mcp"
18
+ DESCRIPTION = "MCP server for InvenTree"
19
+ VERSION = PLUGIN_VERSION
20
+
21
+ # Additional project information
22
+ AUTHOR = "Oliver Walters"
23
+ WEBSITE = "https://github.com/inventree/inventree-mcp"
24
+ LICENSE = "MIT"
25
+
26
+ # Optionally specify supported InvenTree versions
27
+ # MIN_VERSION = '0.18.0'
28
+ # MAX_VERSION = '2.0.0'
29
+
30
+ # Plugin settings (from SettingsMixin)
31
+ # Ref: https://docs.inventree.org/en/latest/plugins/mixins/settings/
32
+ SETTINGS: ClassVar[dict] = {
33
+ "REQUIRE_AUTH": {
34
+ "name": "Require Authentication",
35
+ "description": "Reject unauthenticated requests to the MCP endpoint. Disable only for local testing.",
36
+ "validator": bool,
37
+ "default": True,
38
+ },
39
+ "MCP_READ_ONLY": {
40
+ "name": "Read Only",
41
+ "description": "Block all write actions via the MCP endpoint, regardless of the calling user's permissions.",
42
+ "validator": bool,
43
+ "default": True,
44
+ },
45
+ "MCP_LOG_TOOL_CALLS": {
46
+ "name": "Log Tool Calls",
47
+ "description": "Log every MCP tool call (tool name, arguments, calling user, and outcome) to the 'inventree' logger for debugging.",
48
+ "validator": bool,
49
+ "default": False,
50
+ },
51
+ }
52
+
53
+ # Custom URL endpoints (from UrlsMixin)
54
+ # Ref: https://docs.inventree.org/en/latest/plugins/mixins/urls/
55
+ def setup_urls(self):
56
+ """Configure custom URL endpoints for this plugin."""
57
+ from .mcp_transport import urlpatterns
58
+
59
+ return urlpatterns
@@ -0,0 +1,49 @@
1
+ """Describe a view's optional output-expansion flags (e.g. `part_detail`).
2
+
3
+ Many InvenTree list/detail views can inline a related object's full detail
4
+ into the response on request (`?part_detail=true` on the stock API embeds
5
+ the full Part, avoiding a separate get_part call) rather than only exposing
6
+ its ID. These flags are declared once per view as `output_options`, an
7
+ `InvenTree.fields.OutputConfiguration` subclass listing `InvenTreeOutputOption`
8
+ instances (flag name, default, description) - the same metadata InvenTree's
9
+ own OpenAPI schema is generated from (see `InvenTree.schema.
10
+ schema_for_view_output_options`). Reading it here means this can't drift
11
+ from the real API the way a hand-maintained list per resource would.
12
+
13
+ Not every view declares `output_options` (flat resources with no
14
+ expandable relations, e.g. Company, Attachment, don't need it) -
15
+ describe_output_options() returns an empty dict for those rather than
16
+ raising, same permissive spirit as filter_introspection.py.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Any
22
+
23
+
24
+ def describe_output_options(view_cls: type) -> dict[str, Any]:
25
+ """Describe the optional output-expansion flags available on *view_cls*.
26
+
27
+ Args:
28
+ view_cls: a list or detail view class as used in the real API
29
+ urlconf (e.g. stock.api.StockList or stock.api.StockDetail).
30
+ Both a resource's list and detail view conventionally share the
31
+ same `output_options` class, so either works.
32
+
33
+ Returns:
34
+ A dict of {flag_name: {default_included, description}} - each key
35
+ is a boolean query parameter that can be passed directly in a
36
+ list/get tool's `filters` argument, e.g. filters={"part_detail": true}.
37
+ Empty if the view declares no `output_options`.
38
+ """
39
+ output_config = getattr(view_cls, "output_options", None)
40
+ if output_config is None:
41
+ return {}
42
+
43
+ return {
44
+ option.flag: {
45
+ "default_included": bool(option.default),
46
+ "description": option.description,
47
+ }
48
+ for option in output_config.OPTIONS
49
+ }
@@ -0,0 +1,189 @@
1
+ """Describe a DRF list view's real filter/search/ordering options.
2
+
3
+ Mirrors schema_introspection.py's approach for output shapes: rather than
4
+ hand-maintain a description of what's filterable per resource (which would
5
+ drift as InvenTree's FilterSet classes evolve), this reads the same metadata
6
+ django-filter itself uses (view.filterset_class.base_filters) plus DRF's
7
+ search_fields/ordering_fields, and turns it into a plain dict a calling
8
+ agent can use directly as the `filters` argument on a list_* tool.
9
+
10
+ Some views (e.g. company.api.ContactList/AddressList) skip a full
11
+ filterset_class and instead use DRF's `filterset_fields = [...]` shorthand,
12
+ which makes django_filter's DjangoFilterBackend auto-build an exact-match
13
+ FilterSet at request time - the real API genuinely accepts these as query
14
+ params, but there's no filterset_class for us to introspect. _model_field_filters()
15
+ covers that case by reading the field type off the view's own model instead.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any
21
+
22
+ import django_filters as df
23
+ from django.core.exceptions import FieldDoesNotExist
24
+
25
+ # Best-effort Django model field internal-type -> our type string, used only
26
+ # as a fallback for plain `filterset_fields` (no explicit FilterSet class to
27
+ # read a real django_filters.Filter subclass off of). Not exhaustive - falls
28
+ # back to "string" for anything unlisted, same permissive spirit as
29
+ # _filter_type_name() below.
30
+ _MODEL_FIELD_TYPES: dict[str, str] = {
31
+ "ForeignKey": "integer (id)",
32
+ "OneToOneField": "integer (id)",
33
+ "BooleanField": "boolean",
34
+ "IntegerField": "number",
35
+ "PositiveIntegerField": "number",
36
+ "FloatField": "number",
37
+ "DecimalField": "number",
38
+ "DateTimeField": "string (date-time)",
39
+ "DateField": "string (date)",
40
+ "TimeField": "string (time)",
41
+ "UUIDField": "string (uuid)",
42
+ }
43
+
44
+ # Ordered most-specific-subclass-first: e.g. ModelChoiceFilter is a
45
+ # ChoiceFilter subclass and must be matched before the generic entry below it.
46
+ _ORDERED_FILTER_TYPES: list[tuple[type, str]] = [
47
+ (df.BooleanFilter, "boolean"),
48
+ (df.ModelMultipleChoiceFilter, "array of integer (id)"),
49
+ (df.ModelChoiceFilter, "integer (id)"),
50
+ (df.MultipleChoiceFilter, "array of string (choice)"),
51
+ (df.ChoiceFilter, "string (choice)"),
52
+ (df.NumberFilter, "number"),
53
+ (df.DateTimeFilter, "string (date-time)"),
54
+ (df.DateFilter, "string (date)"),
55
+ (df.TimeFilter, "string (time)"),
56
+ (df.UUIDFilter, "string (uuid)"),
57
+ (df.CharFilter, "string"),
58
+ ]
59
+
60
+
61
+ def _filter_type_name(f: df.Filter) -> str:
62
+ for filter_type, name in _ORDERED_FILTER_TYPES:
63
+ if isinstance(f, filter_type):
64
+ return name
65
+ return "string"
66
+
67
+
68
+ def _filter_info(f: df.Filter) -> dict[str, Any]:
69
+ info: dict[str, Any] = {"type": _filter_type_name(f)}
70
+
71
+ if f.label:
72
+ info["label"] = str(f.label)
73
+
74
+ choices = getattr(f, "extra", {}).get("choices")
75
+ if choices:
76
+ info["choices"] = [value for value, _display in choices]
77
+
78
+ return info
79
+
80
+
81
+ def _model_field_filters(view_cls: type) -> dict[str, Any]:
82
+ """Best-effort filter info for views using DRF's `filterset_fields` shorthand.
83
+
84
+ No django_filters.Filter instances exist to introspect here - only a
85
+ list of field names DjangoFilterBackend auto-generates exact-match
86
+ filters from at request time - so this reads the type straight off the
87
+ view's own model instead.
88
+ """
89
+ field_names = getattr(view_cls, "filterset_fields", None) or []
90
+ model = getattr(getattr(view_cls, "queryset", None), "model", None)
91
+ if not field_names or model is None:
92
+ return {}
93
+
94
+ filters = {}
95
+ for name in field_names:
96
+ try:
97
+ field = model._meta.get_field(name)
98
+ except FieldDoesNotExist:
99
+ filters[name] = {"type": "string"}
100
+ continue
101
+
102
+ filters[name] = {
103
+ "type": _MODEL_FIELD_TYPES.get(field.get_internal_type(), "string")
104
+ }
105
+
106
+ return filters
107
+
108
+
109
+ def _default_ordering_fields(view_cls: type) -> list[str]:
110
+ """Best-effort reproduction of DRF's own fallback for views that don't declare `ordering_fields`.
111
+
112
+ `rest_framework.filters.OrderingFilter.get_default_valid_fields()` doesn't
113
+ treat a missing `ordering_fields` as "no ordering allowed" - it allows
114
+ ordering by any non-write-only field on the view's serializer instead.
115
+ Before this, `describe_filterset()` read `getattr(view_cls,
116
+ 'ordering_fields', None) or []`, which couldn't distinguish "not declared"
117
+ from "declared empty" and reported zero ordering fields either way - e.g.
118
+ `describe_filters("bom_substitute")` claimed no ordering was possible even
119
+ though `ordering=part` genuinely works against the real API
120
+ (`BomItemSubstituteList` doesn't declare `ordering_fields`). Mirrors DRF's
121
+ logic rather than calling it directly: DRF's version needs a bound view
122
+ *instance* (`view.get_serializer_class()`), but this module only ever has
123
+ the view *class* to introspect with, same constraint as the rest of this
124
+ file.
125
+ """
126
+ serializer_class = getattr(view_cls, "serializer_class", None)
127
+ if serializer_class is None:
128
+ return []
129
+
130
+ try:
131
+ fields = serializer_class().fields
132
+ except (TypeError, AssertionError):
133
+ # TypeError: constructor needs args this bare instantiation can't
134
+ # supply. AssertionError: DRF's own internal checks (e.g. a
135
+ # ModelSerializer subclass missing Meta.model). Fail soft to "no
136
+ # ordering fields reported" rather than raising out of a pure
137
+ # introspection helper.
138
+ return []
139
+
140
+ ordering_fields: list[str] = []
141
+ for field in fields.values():
142
+ if getattr(field, "write_only", False) or field.source == "*":
143
+ continue
144
+ source = field.source.replace(".", "__")
145
+ if source not in ordering_fields:
146
+ ordering_fields.append(source)
147
+
148
+ return ordering_fields
149
+
150
+
151
+ def describe_filterset(view_cls: type) -> dict[str, Any]:
152
+ """Describe the search/filter/ordering options available on *view_cls*.
153
+
154
+ Args:
155
+ view_cls: a list view class as used in the real API urlconf (e.g.
156
+ part.api.PartList).
157
+
158
+ Returns:
159
+ A dict with:
160
+ - search_fields: fields matched by a free-text `search` query param.
161
+ - ordering_fields: fields usable as the corresponding list tool's
162
+ `ordering` argument (prefix with '-' for descending). If the view
163
+ doesn't declare `ordering_fields` at all, this reports DRF's own
164
+ fallback (any readable serializer field) rather than an empty list
165
+ - see _default_ordering_fields().
166
+ - filters: {name: {type, label, choices}} - each key is usable
167
+ directly as a query parameter / filters dict entry.
168
+ """
169
+ filterset_class = getattr(view_cls, "filterset_class", None)
170
+
171
+ if filterset_class is not None:
172
+ filters = {
173
+ name: _filter_info(f) for name, f in filterset_class.base_filters.items()
174
+ }
175
+ else:
176
+ filters = _model_field_filters(view_cls)
177
+
178
+ declared_ordering = getattr(view_cls, "ordering_fields", None)
179
+ ordering_fields = (
180
+ list(declared_ordering)
181
+ if declared_ordering is not None
182
+ else _default_ordering_fields(view_cls)
183
+ )
184
+
185
+ return {
186
+ "search_fields": list(getattr(view_cls, "search_fields", None) or []),
187
+ "ordering_fields": ordering_fields,
188
+ "filters": filters,
189
+ }