django-bolt 0.1.0__cp310-abi3-win_amd64.whl

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.

Potentially problematic release.


This version of django-bolt might be problematic. Click here for more details.

Files changed (128) hide show
  1. django_bolt/__init__.py +147 -0
  2. django_bolt/_core.pyd +0 -0
  3. django_bolt/admin/__init__.py +25 -0
  4. django_bolt/admin/admin_detection.py +179 -0
  5. django_bolt/admin/asgi_bridge.py +267 -0
  6. django_bolt/admin/routes.py +91 -0
  7. django_bolt/admin/static.py +155 -0
  8. django_bolt/admin/static_routes.py +111 -0
  9. django_bolt/api.py +1011 -0
  10. django_bolt/apps.py +7 -0
  11. django_bolt/async_collector.py +228 -0
  12. django_bolt/auth/README.md +464 -0
  13. django_bolt/auth/REVOCATION_EXAMPLE.md +391 -0
  14. django_bolt/auth/__init__.py +84 -0
  15. django_bolt/auth/backends.py +236 -0
  16. django_bolt/auth/guards.py +224 -0
  17. django_bolt/auth/jwt_utils.py +212 -0
  18. django_bolt/auth/revocation.py +286 -0
  19. django_bolt/auth/token.py +335 -0
  20. django_bolt/binding.py +363 -0
  21. django_bolt/bootstrap.py +77 -0
  22. django_bolt/cli.py +133 -0
  23. django_bolt/compression.py +104 -0
  24. django_bolt/decorators.py +159 -0
  25. django_bolt/dependencies.py +128 -0
  26. django_bolt/error_handlers.py +305 -0
  27. django_bolt/exceptions.py +294 -0
  28. django_bolt/health.py +129 -0
  29. django_bolt/logging/__init__.py +6 -0
  30. django_bolt/logging/config.py +357 -0
  31. django_bolt/logging/middleware.py +296 -0
  32. django_bolt/management/__init__.py +1 -0
  33. django_bolt/management/commands/__init__.py +0 -0
  34. django_bolt/management/commands/runbolt.py +427 -0
  35. django_bolt/middleware/__init__.py +32 -0
  36. django_bolt/middleware/compiler.py +131 -0
  37. django_bolt/middleware/middleware.py +247 -0
  38. django_bolt/openapi/__init__.py +23 -0
  39. django_bolt/openapi/config.py +196 -0
  40. django_bolt/openapi/plugins.py +439 -0
  41. django_bolt/openapi/routes.py +152 -0
  42. django_bolt/openapi/schema_generator.py +581 -0
  43. django_bolt/openapi/spec/__init__.py +68 -0
  44. django_bolt/openapi/spec/base.py +74 -0
  45. django_bolt/openapi/spec/callback.py +24 -0
  46. django_bolt/openapi/spec/components.py +72 -0
  47. django_bolt/openapi/spec/contact.py +21 -0
  48. django_bolt/openapi/spec/discriminator.py +25 -0
  49. django_bolt/openapi/spec/encoding.py +67 -0
  50. django_bolt/openapi/spec/enums.py +41 -0
  51. django_bolt/openapi/spec/example.py +36 -0
  52. django_bolt/openapi/spec/external_documentation.py +21 -0
  53. django_bolt/openapi/spec/header.py +132 -0
  54. django_bolt/openapi/spec/info.py +50 -0
  55. django_bolt/openapi/spec/license.py +28 -0
  56. django_bolt/openapi/spec/link.py +66 -0
  57. django_bolt/openapi/spec/media_type.py +51 -0
  58. django_bolt/openapi/spec/oauth_flow.py +36 -0
  59. django_bolt/openapi/spec/oauth_flows.py +28 -0
  60. django_bolt/openapi/spec/open_api.py +87 -0
  61. django_bolt/openapi/spec/operation.py +105 -0
  62. django_bolt/openapi/spec/parameter.py +147 -0
  63. django_bolt/openapi/spec/path_item.py +78 -0
  64. django_bolt/openapi/spec/paths.py +27 -0
  65. django_bolt/openapi/spec/reference.py +38 -0
  66. django_bolt/openapi/spec/request_body.py +38 -0
  67. django_bolt/openapi/spec/response.py +48 -0
  68. django_bolt/openapi/spec/responses.py +44 -0
  69. django_bolt/openapi/spec/schema.py +678 -0
  70. django_bolt/openapi/spec/security_requirement.py +28 -0
  71. django_bolt/openapi/spec/security_scheme.py +69 -0
  72. django_bolt/openapi/spec/server.py +34 -0
  73. django_bolt/openapi/spec/server_variable.py +32 -0
  74. django_bolt/openapi/spec/tag.py +32 -0
  75. django_bolt/openapi/spec/xml.py +44 -0
  76. django_bolt/pagination.py +669 -0
  77. django_bolt/param_functions.py +49 -0
  78. django_bolt/params.py +337 -0
  79. django_bolt/request_parsing.py +128 -0
  80. django_bolt/responses.py +214 -0
  81. django_bolt/router.py +48 -0
  82. django_bolt/serialization.py +193 -0
  83. django_bolt/status_codes.py +321 -0
  84. django_bolt/testing/__init__.py +10 -0
  85. django_bolt/testing/client.py +274 -0
  86. django_bolt/testing/helpers.py +93 -0
  87. django_bolt/tests/__init__.py +0 -0
  88. django_bolt/tests/admin_tests/__init__.py +1 -0
  89. django_bolt/tests/admin_tests/conftest.py +6 -0
  90. django_bolt/tests/admin_tests/test_admin_with_django.py +278 -0
  91. django_bolt/tests/admin_tests/urls.py +9 -0
  92. django_bolt/tests/cbv/__init__.py +0 -0
  93. django_bolt/tests/cbv/test_class_views.py +570 -0
  94. django_bolt/tests/cbv/test_class_views_django_orm.py +703 -0
  95. django_bolt/tests/cbv/test_class_views_features.py +1173 -0
  96. django_bolt/tests/cbv/test_class_views_with_client.py +622 -0
  97. django_bolt/tests/conftest.py +165 -0
  98. django_bolt/tests/test_action_decorator.py +399 -0
  99. django_bolt/tests/test_auth_secret_key.py +83 -0
  100. django_bolt/tests/test_decorator_syntax.py +159 -0
  101. django_bolt/tests/test_error_handling.py +481 -0
  102. django_bolt/tests/test_file_response.py +192 -0
  103. django_bolt/tests/test_global_cors.py +172 -0
  104. django_bolt/tests/test_guards_auth.py +441 -0
  105. django_bolt/tests/test_guards_integration.py +303 -0
  106. django_bolt/tests/test_health.py +283 -0
  107. django_bolt/tests/test_integration_validation.py +400 -0
  108. django_bolt/tests/test_json_validation.py +536 -0
  109. django_bolt/tests/test_jwt_auth.py +327 -0
  110. django_bolt/tests/test_jwt_token.py +458 -0
  111. django_bolt/tests/test_logging.py +837 -0
  112. django_bolt/tests/test_logging_merge.py +419 -0
  113. django_bolt/tests/test_middleware.py +492 -0
  114. django_bolt/tests/test_middleware_server.py +230 -0
  115. django_bolt/tests/test_model_viewset.py +323 -0
  116. django_bolt/tests/test_models.py +24 -0
  117. django_bolt/tests/test_pagination.py +1258 -0
  118. django_bolt/tests/test_parameter_validation.py +178 -0
  119. django_bolt/tests/test_syntax.py +626 -0
  120. django_bolt/tests/test_testing_utilities.py +163 -0
  121. django_bolt/tests/test_testing_utilities_simple.py +123 -0
  122. django_bolt/tests/test_viewset_unified.py +346 -0
  123. django_bolt/typing.py +273 -0
  124. django_bolt/views.py +1110 -0
  125. django_bolt-0.1.0.dist-info/METADATA +629 -0
  126. django_bolt-0.1.0.dist-info/RECORD +128 -0
  127. django_bolt-0.1.0.dist-info/WHEEL +4 -0
  128. django_bolt-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,163 @@
1
+ """Tests for django-bolt testing utilities.
2
+
3
+ This file tests the TestClient (V2) that routes through Rust with per-instance state.
4
+ """
5
+ import pytest
6
+ from django_bolt import BoltAPI
7
+ from django_bolt.testing import TestClient, AsyncTestClient
8
+
9
+
10
+ def test_simple_get_request():
11
+ """Test basic GET request with TestClient."""
12
+ api = BoltAPI()
13
+
14
+ @api.get("/hello")
15
+ async def hello():
16
+ return {"message": "world"}
17
+
18
+ with TestClient(api) as client:
19
+ response = client.get("/hello")
20
+ assert response.status_code == 200
21
+ assert response.json() == {"message": "world"}
22
+
23
+
24
+ def test_path_parameters():
25
+ """Test path parameters extraction."""
26
+ api = BoltAPI()
27
+
28
+ @api.get("/users/{user_id}")
29
+ async def get_user(user_id: int):
30
+ return {"id": user_id, "name": f"User {user_id}"}
31
+
32
+ with TestClient(api) as client:
33
+ response = client.get("/users/123")
34
+ assert response.status_code == 200
35
+ data = response.json()
36
+ assert data["id"] == 123
37
+ assert data["name"] == "User 123"
38
+
39
+
40
+ def test_query_parameters():
41
+ """Test query parameters extraction."""
42
+ api = BoltAPI()
43
+
44
+ @api.get("/search")
45
+ async def search(q: str, limit: int = 10):
46
+ return {"query": q, "limit": limit}
47
+
48
+ with TestClient(api) as client:
49
+ response = client.get("/search?q=test&limit=20")
50
+ assert response.status_code == 200
51
+ data = response.json()
52
+ assert data["query"] == "test"
53
+ assert data["limit"] == 20
54
+
55
+
56
+ def test_post_with_body():
57
+ """Test POST with JSON body."""
58
+ import msgspec
59
+
60
+ class UserCreate(msgspec.Struct):
61
+ name: str
62
+ email: str
63
+
64
+ api = BoltAPI()
65
+
66
+ @api.post("/users")
67
+ async def create_user(user: UserCreate):
68
+ return {"id": 1, "name": user.name, "email": user.email}
69
+
70
+ with TestClient(api) as client:
71
+ response = client.post("/users", json={"name": "John", "email": "john@example.com"})
72
+ assert response.status_code == 200
73
+ data = response.json()
74
+ assert data["id"] == 1
75
+ assert data["name"] == "John"
76
+ assert data["email"] == "john@example.com"
77
+
78
+
79
+ def test_404_not_found():
80
+ """Test 404 for non-existent route."""
81
+ api = BoltAPI()
82
+
83
+ @api.get("/hello")
84
+ async def hello():
85
+ return {"message": "world"}
86
+
87
+ with TestClient(api) as client:
88
+ response = client.get("/nonexistent")
89
+ assert response.status_code == 404
90
+
91
+
92
+ def test_multiple_methods():
93
+ """Test different HTTP methods on same path."""
94
+ api = BoltAPI()
95
+
96
+ @api.get("/resource")
97
+ async def get_resource():
98
+ return {"method": "GET"}
99
+
100
+ @api.post("/resource")
101
+ async def create_resource():
102
+ return {"method": "POST"}
103
+
104
+ @api.put("/resource")
105
+ async def update_resource():
106
+ return {"method": "PUT"}
107
+
108
+ @api.delete("/resource")
109
+ async def delete_resource():
110
+ return {"method": "DELETE"}
111
+
112
+ with TestClient(api) as client:
113
+ assert client.get("/resource").json() == {"method": "GET"}
114
+ assert client.post("/resource").json() == {"method": "POST"}
115
+ assert client.put("/resource").json() == {"method": "PUT"}
116
+ assert client.delete("/resource").json() == {"method": "DELETE"}
117
+
118
+
119
+ @pytest.mark.skip(reason="AsyncTestClient has event loop conflict - needs refactoring for nested event loops")
120
+ @pytest.mark.asyncio
121
+ async def test_async_client():
122
+ """Test AsyncTestClient."""
123
+ api = BoltAPI()
124
+
125
+ @api.get("/hello")
126
+ async def hello():
127
+ return {"message": "async world"}
128
+
129
+ async with AsyncTestClient(api) as client:
130
+ response = await client.get("/hello")
131
+ assert response.status_code == 200
132
+ assert response.json() == {"message": "async world"}
133
+
134
+
135
+ def test_headers():
136
+ """Test request headers."""
137
+ from django_bolt.param_functions import Header
138
+ from typing import Annotated
139
+
140
+ api = BoltAPI()
141
+
142
+ @api.get("/with-header")
143
+ async def with_header(x_custom: Annotated[str, Header()]):
144
+ return {"header_value": x_custom}
145
+
146
+ with TestClient(api) as client:
147
+ response = client.get("/with-header", headers={"X-Custom": "test-value"})
148
+ assert response.status_code == 200
149
+ assert response.json() == {"header_value": "test-value"}
150
+
151
+
152
+ def test_status_code():
153
+ """Test custom status codes."""
154
+ api = BoltAPI()
155
+
156
+ @api.post("/created", status_code=201)
157
+ async def create():
158
+ return {"created": True}
159
+
160
+ with TestClient(api) as client:
161
+ response = client.post("/created")
162
+ assert response.status_code == 201
163
+ assert response.json() == {"created": True}
@@ -0,0 +1,123 @@
1
+ """Simple test for django-bolt testing utilities.
2
+
3
+ Tests the new TestClient in a single comprehensive test to avoid
4
+ router re-initialization issues (the Rust global router can only be set once).
5
+ """
6
+ import msgspec
7
+ from typing import Annotated
8
+ from django_bolt import BoltAPI
9
+ from django_bolt.testing import TestClient
10
+ from django_bolt.param_functions import Header
11
+
12
+
13
+ def test_test_client_comprehensive():
14
+ """Comprehensive test of TestClient functionality."""
15
+ api = BoltAPI()
16
+
17
+ # Test 1: Simple GET
18
+ @api.get("/hello")
19
+ async def hello():
20
+ return {"message": "world"}
21
+
22
+ # Test 2: Path parameters
23
+ @api.get("/users/{user_id}")
24
+ async def get_user(user_id: int):
25
+ return {"id": user_id, "name": f"User {user_id}"}
26
+
27
+ # Test 3: Query parameters
28
+ @api.get("/search")
29
+ async def search(q: str, limit: int = 10):
30
+ return {"query": q, "limit": limit}
31
+
32
+ # Test 4: POST with body
33
+ class UserCreate(msgspec.Struct):
34
+ name: str
35
+ email: str
36
+
37
+ @api.post("/users")
38
+ async def create_user(user: UserCreate):
39
+ return {"id": 1, "name": user.name, "email": user.email}
40
+
41
+ # Test 5: Headers
42
+ @api.get("/with-header")
43
+ async def with_header(x_custom: Annotated[str, Header()]):
44
+ return {"header_value": x_custom}
45
+
46
+ # Test 6: Custom status code
47
+ @api.post("/created", status_code=201)
48
+ async def create():
49
+ return {"created": True}
50
+
51
+ # Test 7: Different methods on same path
52
+ @api.get("/resource")
53
+ async def get_resource():
54
+ return {"method": "GET"}
55
+
56
+ @api.post("/resource")
57
+ async def create_resource():
58
+ return {"method": "POST"}
59
+
60
+ # Now run all tests using one client
61
+ with TestClient(api) as client:
62
+ # Test 1: Simple GET
63
+ response = client.get("/hello")
64
+ assert response.status_code == 200
65
+ assert response.json() == {"message": "world"}
66
+ print("✓ Simple GET works")
67
+
68
+ # Test 2: Path parameters
69
+ response = client.get("/users/123")
70
+ assert response.status_code == 200
71
+ data = response.json()
72
+ assert data["id"] == 123
73
+ assert data["name"] == "User 123"
74
+ print("✓ Path parameters work")
75
+
76
+ # Test 3: Query parameters
77
+ response = client.get("/search?q=test&limit=20")
78
+ assert response.status_code == 200
79
+ data = response.json()
80
+ assert data["query"] == "test"
81
+ assert data["limit"] == 20
82
+ print("✓ Query parameters work")
83
+
84
+ # Test 3b: Query parameters with defaults
85
+ response = client.get("/search?q=test2")
86
+ assert response.status_code == 200
87
+ data = response.json()
88
+ assert data["query"] == "test2"
89
+ assert data["limit"] == 10
90
+ print("✓ Query parameters with defaults work")
91
+
92
+ # Test 4: POST with JSON body
93
+ response = client.post("/users", json={"name": "John", "email": "john@example.com"})
94
+ assert response.status_code == 200
95
+ data = response.json()
96
+ assert data["id"] == 1
97
+ assert data["name"] == "John"
98
+ assert data["email"] == "john@example.com"
99
+ print("✓ POST with JSON body works")
100
+
101
+ # Test 5: Headers
102
+ response = client.get("/with-header", headers={"X-Custom": "test-value"})
103
+ assert response.status_code == 200
104
+ assert response.json() == {"header_value": "test-value"}
105
+ print("✓ Headers work")
106
+
107
+ # Test 6: Custom status code
108
+ response = client.post("/created")
109
+ assert response.status_code == 201
110
+ assert response.json() == {"created": True}
111
+ print("✓ Custom status codes work")
112
+
113
+ # Test 7: Different methods
114
+ assert client.get("/resource").json() == {"method": "GET"}
115
+ assert client.post("/resource").json() == {"method": "POST"}
116
+ print("✓ Multiple HTTP methods work")
117
+
118
+ # Test 8: 404
119
+ response = client.get("/nonexistent")
120
+ assert response.status_code == 404
121
+ print("✓ 404 for non-existent routes works")
122
+
123
+ print("\n✅ All test client features working correctly!")
@@ -0,0 +1,346 @@
1
+ """
2
+ Tests for unified ViewSet pattern with api.viewset() (Litestar/DRF-inspired).
3
+
4
+ This test suite verifies that the new unified ViewSet pattern works correctly:
5
+ - Single ViewSet for both list and detail views
6
+ - DRF-style action methods (list, retrieve, create, update, partial_update, destroy)
7
+ - Automatic route generation with api.viewset()
8
+ - Different serializers for list vs detail (list_serializer_class)
9
+ - Type-driven serialization
10
+ """
11
+ import pytest
12
+ import msgspec
13
+ from django_bolt import BoltAPI, ViewSet, action
14
+ from django_bolt.testing import TestClient
15
+ from .test_models import Article
16
+
17
+
18
+ # --- Schemas ---
19
+
20
+ class ArticleFullSchema(msgspec.Struct):
21
+ """Full article schema for detail views."""
22
+ id: int
23
+ title: str
24
+ content: str
25
+ author: str
26
+ is_published: bool
27
+
28
+ @classmethod
29
+ def from_model(cls, obj):
30
+ return cls(
31
+ id=obj.id,
32
+ title=obj.title,
33
+ content=obj.content,
34
+ author=obj.author,
35
+ is_published=obj.is_published,
36
+ )
37
+
38
+
39
+ class ArticleMiniSchema(msgspec.Struct):
40
+ """Minimal article schema for list views."""
41
+ id: int
42
+ title: str
43
+
44
+ @classmethod
45
+ def from_model(cls, obj):
46
+ return cls(id=obj.id, title=obj.title)
47
+
48
+
49
+ class ArticleCreateSchema(msgspec.Struct):
50
+ """Schema for creating articles."""
51
+ title: str
52
+ content: str
53
+ author: str
54
+
55
+
56
+ class ArticleUpdateSchema(msgspec.Struct):
57
+ """Schema for updating articles."""
58
+ title: str | None = None
59
+ content: str | None = None
60
+ author: str | None = None
61
+
62
+
63
+ # --- Tests ---
64
+
65
+ @pytest.mark.django_db(transaction=True)
66
+ def test_unified_viewset_basic_crud(api):
67
+ """Test unified ViewSet with basic CRUD operations."""
68
+
69
+ class ArticleViewSet(ViewSet):
70
+ """Unified ViewSet for articles."""
71
+ queryset = Article.objects.all()
72
+ serializer_class = ArticleFullSchema
73
+ list_serializer_class = ArticleMiniSchema
74
+ lookup_field = 'pk'
75
+
76
+ async def list(self, request):
77
+ """List articles."""
78
+ articles = []
79
+ async for article in await self.get_queryset():
80
+ articles.append(ArticleMiniSchema.from_model(article))
81
+ return articles
82
+
83
+ async def retrieve(self, request, pk: int):
84
+ """Retrieve a single article."""
85
+ article = await self.get_object(pk)
86
+ return ArticleFullSchema.from_model(article)
87
+
88
+ async def create(self, request, data: ArticleCreateSchema):
89
+ """Create a new article."""
90
+ article = await Article.objects.acreate(
91
+ title=data.title,
92
+ content=data.content,
93
+ author=data.author,
94
+ )
95
+ return ArticleFullSchema.from_model(article)
96
+
97
+ async def update(self, request, pk: int, data: ArticleUpdateSchema):
98
+ """Update an article."""
99
+ article = await self.get_object(pk)
100
+ if data.title:
101
+ article.title = data.title
102
+ if data.content:
103
+ article.content = data.content
104
+ if data.author:
105
+ article.author = data.author
106
+ await article.asave()
107
+ return ArticleFullSchema.from_model(article)
108
+
109
+ async def partial_update(self, request, pk: int, data: ArticleUpdateSchema):
110
+ """Partially update an article."""
111
+ article = await self.get_object(pk)
112
+ if data.title:
113
+ article.title = data.title
114
+ if data.content:
115
+ article.content = data.content
116
+ if data.author:
117
+ article.author = data.author
118
+ await article.asave()
119
+ return ArticleFullSchema.from_model(article)
120
+
121
+ async def destroy(self, request, pk: int):
122
+ """Delete an article."""
123
+ article = await self.get_object(pk)
124
+ await article.adelete()
125
+ return {"deleted": True, "id": pk}
126
+
127
+ # Register with api.viewset() - automatic route generation
128
+ @api.viewset("/articles")
129
+ class ArticleViewSetRegistered(ArticleViewSet):
130
+ pass
131
+
132
+ with TestClient(api) as client:
133
+ # List (empty)
134
+ response = client.get("/articles")
135
+ assert response.status_code == 200
136
+ assert response.json() == []
137
+
138
+ # Create
139
+ response = client.post(
140
+ "/articles",
141
+ json={"title": "Test Article", "content": "Test Content", "author": "Test Author"},
142
+ )
143
+ assert response.status_code == 201 # HTTP 201 Created
144
+ data = response.json()
145
+ assert data["title"] == "Test Article"
146
+ assert data["content"] == "Test Content"
147
+ article_id = data["id"]
148
+
149
+ # List (with data)
150
+ response = client.get("/articles")
151
+ assert response.status_code == 200
152
+ articles = response.json()
153
+ assert len(articles) == 1
154
+ assert articles[0]["title"] == "Test Article"
155
+ # List returns mini schema (id, title only)
156
+ assert "content" not in articles[0]
157
+
158
+ # Retrieve (detail view)
159
+ response = client.get(f"/articles/{article_id}")
160
+ assert response.status_code == 200
161
+ data = response.json()
162
+ assert data["title"] == "Test Article"
163
+ assert data["content"] == "Test Content" # Detail view includes content
164
+
165
+ # Update
166
+ response = client.put(
167
+ f"/articles/{article_id}",
168
+ json={"title": "Updated Title", "content": "Updated Content", "author": "Updated Author"},
169
+ )
170
+ assert response.status_code == 200
171
+ assert response.json()["title"] == "Updated Title"
172
+
173
+ # Partial update
174
+ response = client.patch(
175
+ f"/articles/{article_id}",
176
+ json={"title": "Patched Title"},
177
+ )
178
+ assert response.status_code == 200
179
+ assert response.json()["title"] == "Patched Title"
180
+
181
+ # Delete
182
+ response = client.delete(f"/articles/{article_id}")
183
+ assert response.status_code == 204 # HTTP 204 No Content
184
+ assert response.json()["deleted"] is True
185
+
186
+ # Verify deletion
187
+ response = client.get(f"/articles/{article_id}")
188
+ assert response.status_code == 404
189
+
190
+
191
+ @pytest.mark.django_db(transaction=True)
192
+ def test_unified_viewset_custom_lookup_field(api):
193
+ """Test unified ViewSet with custom lookup_field."""
194
+ from asgiref.sync import async_to_sync
195
+
196
+ # Create article
197
+ article = async_to_sync(Article.objects.acreate)(
198
+ title="Test Article",
199
+ content="Test Content",
200
+ author="test-author-slug",
201
+ )
202
+
203
+ class ArticleViewSet(ViewSet):
204
+ """Unified ViewSet with custom lookup field."""
205
+ queryset = Article.objects.all()
206
+ serializer_class = ArticleFullSchema
207
+ lookup_field = 'author' # Use author as lookup field
208
+
209
+ async def retrieve(self, request, author: str):
210
+ """Retrieve article by author."""
211
+ article = await self.get_object(author=author)
212
+ return ArticleFullSchema.from_model(article)
213
+
214
+ # Register with api.viewset() - uses lookup_field from class
215
+ @api.viewset("/articles")
216
+ class ArticleViewSetRegistered(ArticleViewSet):
217
+ pass
218
+
219
+ with TestClient(api) as client:
220
+ # Lookup by author (using custom lookup_field)
221
+ response = client.get("/articles/test-author-slug")
222
+ assert response.status_code == 200
223
+ data = response.json()
224
+ assert data["author"] == "test-author-slug"
225
+ assert data["title"] == "Test Article"
226
+
227
+
228
+ @pytest.mark.django_db(transaction=True)
229
+ def test_unified_viewset_with_custom_actions(api):
230
+ """Test unified ViewSet with custom actions."""
231
+ from asgiref.sync import async_to_sync
232
+
233
+ # Create test data
234
+ async_to_sync(Article.objects.acreate)(
235
+ title="Published Article",
236
+ content="Content",
237
+ author="Author",
238
+ is_published=True,
239
+ )
240
+ async_to_sync(Article.objects.acreate)(
241
+ title="Draft Article",
242
+ content="Content",
243
+ author="Author",
244
+ is_published=False,
245
+ )
246
+
247
+ class ArticleViewSet(ViewSet):
248
+ """Unified ViewSet with custom actions."""
249
+ queryset = Article.objects.all()
250
+ serializer_class = ArticleFullSchema
251
+ list_serializer_class = ArticleMiniSchema
252
+
253
+ async def list(self, request):
254
+ """List all articles."""
255
+ articles = []
256
+ async for article in await self.get_queryset():
257
+ articles.append(ArticleMiniSchema.from_model(article))
258
+ return articles
259
+
260
+ # Custom action: search (using @action decorator)
261
+ @action(methods=["GET"], detail=False)
262
+ async def search(self, request, query: str):
263
+ """Search articles by title. GET /articles/search"""
264
+ results = []
265
+ async for article in Article.objects.filter(title__icontains=query):
266
+ results.append(ArticleMiniSchema.from_model(article))
267
+ return {"query": query, "results": results}
268
+
269
+ # Custom action: published only (using @action decorator)
270
+ @action(methods=["GET"], detail=False)
271
+ async def published(self, request):
272
+ """Get published articles only. GET /articles/published"""
273
+ articles = []
274
+ async for article in Article.objects.filter(is_published=True):
275
+ articles.append(ArticleMiniSchema.from_model(article))
276
+ return articles
277
+
278
+ # Register with api.viewset() - automatically discovers custom actions
279
+ @api.viewset("/articles")
280
+ class ArticleViewSetRegistered(ArticleViewSet):
281
+ pass
282
+
283
+ with TestClient(api) as client:
284
+ # List all articles
285
+ response = client.get("/articles")
286
+ assert response.status_code == 200
287
+ assert len(response.json()) == 2
288
+
289
+ # Search
290
+ response = client.get("/articles/search?query=Published")
291
+ assert response.status_code == 200
292
+ data = response.json()
293
+ assert data["query"] == "Published"
294
+ assert len(data["results"]) == 1
295
+ assert data["results"][0]["title"] == "Published Article"
296
+
297
+ # Published only
298
+ response = client.get("/articles/published")
299
+ assert response.status_code == 200
300
+ articles = response.json()
301
+ assert len(articles) == 1
302
+ assert articles[0]["title"] == "Published Article"
303
+
304
+
305
+ @pytest.mark.django_db(transaction=True)
306
+ def test_unified_viewset_partial_implementation(api):
307
+ """Test unified ViewSet with only some actions implemented."""
308
+
309
+ class ReadOnlyArticleViewSet(ViewSet):
310
+ """Read-only ViewSet (only list and retrieve)."""
311
+ queryset = Article.objects.all()
312
+ serializer_class = ArticleFullSchema
313
+
314
+ async def list(self, request):
315
+ """List articles."""
316
+ articles = []
317
+ async for article in await self.get_queryset():
318
+ articles.append(ArticleFullSchema.from_model(article))
319
+ return articles
320
+
321
+ async def retrieve(self, request, pk: int):
322
+ """Retrieve a single article."""
323
+ article = await self.get_object(pk)
324
+ return ArticleFullSchema.from_model(article)
325
+
326
+ # Note: create, update, partial_update, destroy not implemented
327
+
328
+ # Register with api.viewset() - only generates routes for implemented actions
329
+ @api.viewset("/articles")
330
+ class ReadOnlyArticleViewSetRegistered(ReadOnlyArticleViewSet):
331
+ pass
332
+
333
+ with TestClient(api) as client:
334
+ # List works
335
+ response = client.get("/articles")
336
+ assert response.status_code == 200
337
+
338
+ # POST not registered (create not implemented)
339
+ response = client.post("/articles", json={"title": "Test", "content": "Test", "author": "Test"})
340
+ assert response.status_code == 404
341
+
342
+
343
+ @pytest.fixture
344
+ def api():
345
+ """Create a fresh BoltAPI instance for each test."""
346
+ return BoltAPI()