|
18 | 18 | import jsonschema |
19 | 19 | from asgiref.sync import sync_to_async |
20 | 20 | from build.models import Build, BuildItem |
| 21 | +from common.models import Attachment, Parameter, ParameterTemplate |
21 | 22 | from company.models import Address, Company, Contact, ManufacturerPart, SupplierPart |
22 | 23 | from django.contrib.auth import get_user_model |
| 24 | +from django.contrib.contenttypes.models import ContentType |
23 | 25 | from django.test import Client, override_settings |
24 | 26 | from django.utils import timezone |
25 | 27 | from InvenTree.unit_test import InvenTreeTestCase |
|
47 | 49 | from .schema_introspection import paginated_schema, serializer_schema |
48 | 50 | from .settings import get_plugin_setting |
49 | 51 | from .tools._common import DEFAULT_LIMIT, MAX_LIMIT, build_query_params, clamp_limit |
| 52 | +from .tools.attachments import get_attachment, list_attachments |
50 | 53 | from .tools.bom import ( |
51 | 54 | get_bom_item, |
52 | 55 | get_bom_substitute, |
|
72 | 75 | ) |
73 | 76 | from .tools.discovery import describe_filters |
74 | 77 | from .tools.locations import get_location, list_locations |
| 78 | +from .tools.parameters import ( |
| 79 | + get_parameter, |
| 80 | + get_parameter_template, |
| 81 | + list_parameter_templates, |
| 82 | + list_parameters, |
| 83 | +) |
75 | 84 | from .tools.parts import get_part, list_parts |
76 | 85 | from .tools.purchase_orders import ( |
77 | 86 | get_purchase_order, |
@@ -234,6 +243,30 @@ def setUpTestData(cls): |
234 | 243 | bom_item=cls.bom_item, part=cls.substitute_part |
235 | 244 | ) |
236 | 245 |
|
| 246 | + # --- Attachment/Parameter fixtures --- |
| 247 | + # Both model_type fields are real ContentType FKs at the ORM level, |
| 248 | + # despite each serializing over the wire as a plain string in a |
| 249 | + # *different* format per resource - see tools/attachments.py's and |
| 250 | + # tools/parameters.py's module docstrings for why they're not |
| 251 | + # interchangeable. |
| 252 | + cls.attachment = Attachment.objects.create( |
| 253 | + model_type="part", |
| 254 | + model_id=cls.part.pk, |
| 255 | + link="https://example.org/datasheet.pdf", |
| 256 | + comment="Test datasheet", |
| 257 | + ) |
| 258 | + cls.parameter_template = ParameterTemplate.objects.create( |
| 259 | + name="Resistance", |
| 260 | + units="ohm", |
| 261 | + model_type=ContentType.objects.get_for_model(Part), |
| 262 | + ) |
| 263 | + cls.parameter = Parameter.objects.create( |
| 264 | + model_type=ContentType.objects.get_for_model(Part), |
| 265 | + model_id=cls.part.pk, |
| 266 | + template=cls.parameter_template, |
| 267 | + data="100", |
| 268 | + ) |
| 269 | + |
237 | 270 | # A second user, deliberately given no roles at all. |
238 | 271 | cls.no_access_user = get_user_model().objects.create_user( |
239 | 272 | username="noaccess", password="password", email="noaccess@example.org" |
@@ -393,6 +426,9 @@ async def test_ordering_argument_reaches_every_list_tool(self): |
393 | 426 | "supplier_part": list_supplier_parts, |
394 | 427 | "bom_item": list_bom_items, |
395 | 428 | "bom_substitute": list_bom_substitutes, |
| 429 | + "attachment": list_attachments, |
| 430 | + "parameter": list_parameters, |
| 431 | + "parameter_template": list_parameter_templates, |
396 | 432 | } |
397 | 433 |
|
398 | 434 | for resource, tool_fn in list_tools_by_resource.items(): |
@@ -711,6 +747,82 @@ async def test_unauthorized_user_cannot_access_bom_data(self): |
711 | 747 | with self.assertRaises(ToolError): |
712 | 748 | await get_bom_substitute(self.bom_substitute.pk) |
713 | 749 |
|
| 750 | + async def test_authorized_user_can_list_and_get_attachments(self): |
| 751 | + self._as(self.user) |
| 752 | + |
| 753 | + listed = await list_attachments(model_type="part", model_id=self.part.pk) |
| 754 | + ids = [a["pk"] for a in listed["results"]] |
| 755 | + self.assertIn(self.attachment.pk, ids) |
| 756 | + |
| 757 | + detail = await get_attachment(self.attachment.pk) |
| 758 | + self.assertEqual(detail["comment"], "Test datasheet") |
| 759 | + |
| 760 | + # cls.attachment is a link, not an uploaded file, so is_image=False |
| 761 | + # must include it and is_image=True must exclude it. |
| 762 | + not_images = await list_attachments(is_image=False) |
| 763 | + self.assertIn(self.attachment.pk, [a["pk"] for a in not_images["results"]]) |
| 764 | + images_only = await list_attachments(is_image=True) |
| 765 | + self.assertNotIn(self.attachment.pk, [a["pk"] for a in images_only["results"]]) |
| 766 | + |
| 767 | + async def test_unauthorized_user_can_still_read_attachments(self): |
| 768 | + """Deliberately the opposite assertion from every other resource's denial test. |
| 769 | +
|
| 770 | + AttachmentList/Detail have no RolePermission/RuleSet gate on reads - |
| 771 | + only IsAuthenticatedOrReadScope (any authenticated user) - see |
| 772 | + tools/attachments.py's module docstring. A zero-role user must still |
| 773 | + succeed here; asserting ToolError (the pattern used everywhere else |
| 774 | + in this file) would be testing for the wrong thing and would mask a |
| 775 | + real regression if this view's permissions ever tightened. |
| 776 | + """ |
| 777 | + self._as(self.no_access_user) |
| 778 | + |
| 779 | + listed = await list_attachments(model_type="part", model_id=self.part.pk) |
| 780 | + ids = [a["pk"] for a in listed["results"]] |
| 781 | + self.assertIn(self.attachment.pk, ids) |
| 782 | + |
| 783 | + detail = await get_attachment(self.attachment.pk) |
| 784 | + self.assertEqual(detail["pk"], self.attachment.pk) |
| 785 | + |
| 786 | + async def test_authorized_user_can_list_and_get_parameters(self): |
| 787 | + self._as(self.user) |
| 788 | + |
| 789 | + listed = await list_parameters(model_type="part.part", model_id=self.part.pk) |
| 790 | + ids = [p["pk"] for p in listed["results"]] |
| 791 | + self.assertIn(self.parameter.pk, ids) |
| 792 | + |
| 793 | + detail = await get_parameter(self.parameter.pk) |
| 794 | + self.assertEqual(detail["data"], "100") |
| 795 | + |
| 796 | + by_template = await list_parameters(template=self.parameter_template.pk) |
| 797 | + self.assertIn(self.parameter.pk, [p["pk"] for p in by_template["results"]]) |
| 798 | + |
| 799 | + async def test_authorized_user_can_list_and_get_parameter_templates(self): |
| 800 | + self._as(self.user) |
| 801 | + |
| 802 | + listed = await list_parameter_templates(search="Resistance") |
| 803 | + names = [t["name"] for t in listed["results"]] |
| 804 | + self.assertIn("Resistance", names) |
| 805 | + |
| 806 | + detail = await get_parameter_template(self.parameter_template.pk) |
| 807 | + self.assertEqual(detail["units"], "ohm") |
| 808 | + |
| 809 | + async def test_unauthorized_user_can_still_read_parameters(self): |
| 810 | + """Same "opposite of every other resource" case as attachments, above - |
| 811 | + ParameterList/Detail and ParameterTemplateList/Detail have no |
| 812 | + RolePermission/RuleSet gate on reads either. |
| 813 | + """ |
| 814 | + self._as(self.no_access_user) |
| 815 | + |
| 816 | + listed = await list_parameters(model_type="part.part", model_id=self.part.pk) |
| 817 | + ids = [p["pk"] for p in listed["results"]] |
| 818 | + self.assertIn(self.parameter.pk, ids) |
| 819 | + |
| 820 | + detail = await get_parameter(self.parameter.pk) |
| 821 | + self.assertEqual(detail["pk"], self.parameter.pk) |
| 822 | + |
| 823 | + templates = await list_parameter_templates() |
| 824 | + self.assertTrue(templates["results"]) |
| 825 | + |
714 | 826 | async def test_authorized_user_can_list_and_get_companies(self): |
715 | 827 | self._as(self.user) |
716 | 828 |
|
@@ -956,6 +1068,20 @@ def test_describe_filters_covers_bom_resources(self): |
956 | 1068 | {"part": {"type": "integer (id)"}, "bom_item": {"type": "integer (id)"}}, |
957 | 1069 | ) |
958 | 1070 |
|
| 1071 | + def test_describe_filters_covers_attachment_and_parameter_resources(self): |
| 1072 | + attachment_filters = describe_filters("attachment")["filters"] |
| 1073 | + self.assertIn("model_type", attachment_filters) |
| 1074 | + self.assertIn("model_id", attachment_filters) |
| 1075 | + self.assertIn("is_image", attachment_filters) |
| 1076 | + |
| 1077 | + parameter_filters = describe_filters("parameter")["filters"] |
| 1078 | + self.assertIn("model_id", parameter_filters) |
| 1079 | + self.assertIn("template", parameter_filters) |
| 1080 | + |
| 1081 | + template_filters = describe_filters("parameter_template")["filters"] |
| 1082 | + self.assertIn("units", template_filters) |
| 1083 | + self.assertIn("has_choices", template_filters) |
| 1084 | + |
959 | 1085 | def test_describe_filters_covers_filterset_fields_shorthand(self): |
960 | 1086 | """Contact/AddressList use DRF's filterset_fields shorthand, not a full |
961 | 1087 | filterset_class - regression test for the model-field fallback in |
|
0 commit comments