1818import jsonschema
1919from asgiref .sync import sync_to_async
2020from build .models import Build , BuildItem
21- from common .models import Attachment , Parameter , ParameterTemplate
21+ from common .models import Attachment , Parameter , ParameterTemplate , ProjectCode
2222from company .models import Address , Company , Contact , ManufacturerPart , SupplierPart
2323from django .contrib .auth import get_user_model
2424from django .contrib .contenttypes .models import ContentType
3030from order .models import (
3131 PurchaseOrder ,
3232 PurchaseOrderLineItem ,
33+ ReturnOrder ,
34+ ReturnOrderLineItem ,
3335 SalesOrder ,
3436 SalesOrderAllocation ,
3537 SalesOrderLineItem ,
3638 SalesOrderShipment ,
3739)
3840from part .api import PartList
39- from part .models import BomItem , BomItemSubstitute , Part , PartCategory
41+ from part .models import BomItem , BomItemSubstitute , Part , PartCategory , PartTestTemplate
4042from part .serializers import PartSerializer
4143from plugin import registry
42- from stock .models import StockItem , StockLocation
44+ from stock .models import (
45+ StockItem ,
46+ StockItemTestResult ,
47+ StockItemTracking ,
48+ StockLocation ,
49+ )
4350from users .models import ApiToken
4451
4552from . import context
8289 list_parameters ,
8390)
8491from .tools .parts import get_part , list_parts
92+ from .tools .project_codes import get_project_code , list_project_codes
8593from .tools .purchase_orders import (
8694 get_purchase_order ,
8795 get_purchase_order_line ,
8896 list_purchase_order_lines ,
8997 list_purchase_orders ,
9098)
99+ from .tools .return_orders import (
100+ get_return_order ,
101+ get_return_order_line ,
102+ list_return_order_lines ,
103+ list_return_orders ,
104+ )
91105from .tools .sales_orders import (
92106 get_sales_order ,
93107 get_sales_order_allocation ,
97111 list_sales_orders ,
98112)
99113from .tools .stock import get_stock_item , list_stock_items
114+ from .tools .stock_history import (
115+ get_stock_test_result ,
116+ get_stock_tracking ,
117+ list_stock_test_results ,
118+ list_stock_tracking ,
119+ )
100120from .tools .supplier_parts import (
101121 get_manufacturer_part ,
102122 get_supplier_part ,
@@ -116,6 +136,7 @@ class MCPToolPermissionTest(InvenTreeTestCase):
116136 "stock_location.view" ,
117137 "purchase_order.view" ,
118138 "sales_order.view" ,
139+ "return_order.view" ,
119140 "build.view" ,
120141 ]
121142
@@ -133,6 +154,7 @@ def setUpTestData(cls):
133154 category = cls .category ,
134155 salable = True ,
135156 purchaseable = True ,
157+ testable = True ,
136158 )
137159 cls .location = StockLocation .objects .create (
138160 name = "Test Location" , description = "A location for MCP tests"
@@ -267,6 +289,30 @@ def setUpTestData(cls):
267289 data = "100" ,
268290 )
269291
292+ # --- Return order fixtures ---
293+ cls .return_order = ReturnOrder .objects .create (
294+ customer = cls .customer , reference = "RMA-MCP-0001"
295+ )
296+ cls .ro_line = ReturnOrderLineItem .objects .create (
297+ order = cls .return_order , item = cls .stock_item
298+ )
299+
300+ # --- Stock tracking / test result fixtures ---
301+ cls .stock_tracking = StockItemTracking .objects .create (
302+ item = cls .stock_item , notes = "Test tracking entry"
303+ )
304+ cls .test_template = PartTestTemplate .objects .create (
305+ part = cls .part , test_name = "Continuity Test"
306+ )
307+ cls .stock_test_result = StockItemTestResult .objects .create (
308+ stock_item = cls .stock_item , template = cls .test_template , result = True
309+ )
310+
311+ # --- Project code fixtures ---
312+ cls .project_code = ProjectCode .objects .create (
313+ code = "MCP-PROJ" , description = "Test project code"
314+ )
315+
270316 # A second user, deliberately given no roles at all.
271317 cls .no_access_user = get_user_model ().objects .create_user (
272318 username = "noaccess" , password = "password" , email = "noaccess@example.org"
@@ -398,7 +444,7 @@ async def test_ordering_argument_reaches_every_list_tool(self):
398444 """The deep sort-order tests above only exercise list_stock_items - every other
399445 list tool's own `if ordering is not None: base["ordering"] = ordering` line
400446 still needs at least one real call with ordering set, or it's dead code as
401- far as the test suite can tell. Spot-checks all 19 list tools at once.
447+ far as the test suite can tell. Spot-checks all 24 list tools at once.
402448
403449 Uses each resource's own real ordering_fields (via describe_filters) rather
404450 than a hardcoded field name per tool, so this can't silently drift out of
@@ -429,6 +475,11 @@ async def test_ordering_argument_reaches_every_list_tool(self):
429475 "attachment" : list_attachments ,
430476 "parameter" : list_parameters ,
431477 "parameter_template" : list_parameter_templates ,
478+ "return_order" : list_return_orders ,
479+ "return_order_line" : list_return_order_lines ,
480+ "stock_tracking" : list_stock_tracking ,
481+ "stock_test_result" : list_stock_test_results ,
482+ "project_code" : list_project_codes ,
432483 }
433484
434485 for resource , tool_fn in list_tools_by_resource .items ():
@@ -897,6 +948,101 @@ async def test_unauthorized_user_cannot_access_company_catalog_data(self):
897948 with self .assertRaises (ToolError ):
898949 await get_supplier_part (self .supplier_part .pk )
899950
951+ async def test_authorized_user_can_list_and_get_return_orders (self ):
952+ self ._as (self .user )
953+
954+ listed = await list_return_orders (customer = self .customer .pk )
955+ refs = [o ["reference" ] for o in listed ["results" ]]
956+ self .assertIn ("RMA-MCP-0001" , refs )
957+
958+ detail = await get_return_order (self .return_order .pk )
959+ self .assertEqual (detail ["reference" ], "RMA-MCP-0001" )
960+
961+ async def test_authorized_user_can_list_and_get_return_order_lines (self ):
962+ self ._as (self .user )
963+
964+ listed = await list_return_order_lines (order = self .return_order .pk )
965+ ids = [line ["pk" ] for line in listed ["results" ]]
966+ self .assertIn (self .ro_line .pk , ids )
967+
968+ detail = await get_return_order_line (self .ro_line .pk )
969+ self .assertEqual (detail ["order" ], self .return_order .pk )
970+
971+ async def test_unauthorized_user_cannot_access_return_order_data (self ):
972+ self ._as (self .no_access_user )
973+
974+ with self .assertRaises (ToolError ):
975+ await list_return_orders ()
976+ with self .assertRaises (ToolError ):
977+ await get_return_order (self .return_order .pk )
978+ with self .assertRaises (ToolError ):
979+ await list_return_order_lines ()
980+ with self .assertRaises (ToolError ):
981+ await get_return_order_line (self .ro_line .pk )
982+
983+ async def test_authorized_user_can_list_and_get_stock_tracking (self ):
984+ self ._as (self .user )
985+
986+ listed = await list_stock_tracking (item = self .stock_item .pk )
987+ ids = [entry ["pk" ] for entry in listed ["results" ]]
988+ self .assertIn (self .stock_tracking .pk , ids )
989+
990+ detail = await get_stock_tracking (self .stock_tracking .pk )
991+ self .assertEqual (detail ["item" ], self .stock_item .pk )
992+
993+ async def test_authorized_user_can_list_and_get_stock_test_results (self ):
994+ self ._as (self .user )
995+
996+ listed = await list_stock_test_results (stock_item = self .stock_item .pk )
997+ ids = [r ["pk" ] for r in listed ["results" ]]
998+ self .assertIn (self .stock_test_result .pk , ids )
999+
1000+ detail = await get_stock_test_result (self .stock_test_result .pk )
1001+ self .assertEqual (detail ["template" ], self .test_template .pk )
1002+
1003+ async def test_unauthorized_user_cannot_access_stock_history_data (self ):
1004+ """StockItemTracking/StockItemTestResult are both mapped to the 'stock'
1005+ ruleset (users/ruleset.py) - a zero-role user must be denied here the
1006+ same as list_stock_items.
1007+ """
1008+ self ._as (self .no_access_user )
1009+
1010+ with self .assertRaises (ToolError ):
1011+ await list_stock_tracking ()
1012+ with self .assertRaises (ToolError ):
1013+ await get_stock_tracking (self .stock_tracking .pk )
1014+ with self .assertRaises (ToolError ):
1015+ await list_stock_test_results ()
1016+ with self .assertRaises (ToolError ):
1017+ await get_stock_test_result (self .stock_test_result .pk )
1018+
1019+ async def test_authorized_user_can_list_and_get_project_codes (self ):
1020+ self ._as (self .user )
1021+
1022+ listed = await list_project_codes (search = "MCP-PROJ" )
1023+ codes = [c ["code" ] for c in listed ["results" ]]
1024+ self .assertIn ("MCP-PROJ" , codes )
1025+
1026+ detail = await get_project_code (self .project_code .pk )
1027+ self .assertEqual (detail ["code" ], "MCP-PROJ" )
1028+
1029+ async def test_unauthorized_user_can_still_read_project_codes (self ):
1030+ """Deliberately the opposite assertion from most other resources' denial tests.
1031+
1032+ ProjectCodeList/Detail use IsStaffOrReadOnlyScope (see
1033+ tools/project_codes.py's module docstring) - any authenticated user
1034+ can read, not just staff or a specific-role holder. Asserting
1035+ ToolError here (the pattern used everywhere else) would mask a real
1036+ regression if this view's permissions ever tightened.
1037+ """
1038+ self ._as (self .no_access_user )
1039+
1040+ listed = await list_project_codes (search = "MCP-PROJ" )
1041+ self .assertIn ("MCP-PROJ" , [c ["code" ] for c in listed ["results" ]])
1042+
1043+ detail = await get_project_code (self .project_code .pk )
1044+ self .assertEqual (detail ["pk" ], self .project_code .pk )
1045+
9001046
9011047class OutputSchemaTest (InvenTreeTestCase ):
9021048 """Verify tool output schemas are derived from the real serializers, not left blank.
@@ -943,6 +1089,9 @@ async def test_registered_tools_report_output_schemas(self):
9431089 self .assertIsNotNone (tools ["get_build_item" ].outputSchema )
9441090 self .assertIsNotNone (tools ["list_companies" ].outputSchema )
9451091 self .assertIsNotNone (tools ["get_supplier_part" ].outputSchema )
1092+ self .assertIsNotNone (tools ["list_return_orders" ].outputSchema )
1093+ self .assertIsNotNone (tools ["get_stock_tracking" ].outputSchema )
1094+ self .assertIsNotNone (tools ["list_project_codes" ].outputSchema )
9461095
9471096 async def test_every_registered_tool_has_an_output_schema (self ):
9481097 """Guard against a new tool being added without a matching entry in output_schemas.py."""
@@ -1096,6 +1245,28 @@ def test_describe_filters_covers_filterset_fields_shorthand(self):
10961245 {"company" : {"type" : "integer (id)" }},
10971246 )
10981247
1248+ def test_describe_filters_covers_return_order_resources (self ):
1249+ for resource in ("return_order" , "return_order_line" ):
1250+ result = describe_filters (resource )
1251+ self .assertTrue (result ["filters" ])
1252+
1253+ self .assertIn ("outstanding" , describe_filters ("return_order" )["filters" ])
1254+ self .assertIn ("received" , describe_filters ("return_order_line" )["filters" ])
1255+
1256+ def test_describe_filters_covers_stock_history_resources (self ):
1257+ tracking_filters = describe_filters ("stock_tracking" )["filters" ]
1258+ self .assertIn ("item" , tracking_filters )
1259+ self .assertIn ("user" , tracking_filters )
1260+
1261+ result_filters = describe_filters ("stock_test_result" )["filters" ]
1262+ self .assertIn ("template" , result_filters )
1263+ self .assertIn ("result" , result_filters )
1264+
1265+ def test_describe_filters_covers_project_code (self ):
1266+ result = describe_filters ("project_code" )
1267+ self .assertIn ("active" , result ["filters" ])
1268+ self .assertIn ("code" , result ["search_fields" ])
1269+
10991270 def test_describe_filters_rejects_unknown_resource (self ):
11001271 with self .assertRaises (ToolError ):
11011272 describe_filters ("not-a-real-resource" )
0 commit comments