django-bolt 0.1.0__cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.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.
- django_bolt/__init__.py +147 -0
- django_bolt/_core.abi3.so +0 -0
- django_bolt/admin/__init__.py +25 -0
- django_bolt/admin/admin_detection.py +179 -0
- django_bolt/admin/asgi_bridge.py +267 -0
- django_bolt/admin/routes.py +91 -0
- django_bolt/admin/static.py +155 -0
- django_bolt/admin/static_routes.py +111 -0
- django_bolt/api.py +1011 -0
- django_bolt/apps.py +7 -0
- django_bolt/async_collector.py +228 -0
- django_bolt/auth/README.md +464 -0
- django_bolt/auth/REVOCATION_EXAMPLE.md +391 -0
- django_bolt/auth/__init__.py +84 -0
- django_bolt/auth/backends.py +236 -0
- django_bolt/auth/guards.py +224 -0
- django_bolt/auth/jwt_utils.py +212 -0
- django_bolt/auth/revocation.py +286 -0
- django_bolt/auth/token.py +335 -0
- django_bolt/binding.py +363 -0
- django_bolt/bootstrap.py +77 -0
- django_bolt/cli.py +133 -0
- django_bolt/compression.py +104 -0
- django_bolt/decorators.py +159 -0
- django_bolt/dependencies.py +128 -0
- django_bolt/error_handlers.py +305 -0
- django_bolt/exceptions.py +294 -0
- django_bolt/health.py +129 -0
- django_bolt/logging/__init__.py +6 -0
- django_bolt/logging/config.py +357 -0
- django_bolt/logging/middleware.py +296 -0
- django_bolt/management/__init__.py +1 -0
- django_bolt/management/commands/__init__.py +0 -0
- django_bolt/management/commands/runbolt.py +427 -0
- django_bolt/middleware/__init__.py +32 -0
- django_bolt/middleware/compiler.py +131 -0
- django_bolt/middleware/middleware.py +247 -0
- django_bolt/openapi/__init__.py +23 -0
- django_bolt/openapi/config.py +196 -0
- django_bolt/openapi/plugins.py +439 -0
- django_bolt/openapi/routes.py +152 -0
- django_bolt/openapi/schema_generator.py +581 -0
- django_bolt/openapi/spec/__init__.py +68 -0
- django_bolt/openapi/spec/base.py +74 -0
- django_bolt/openapi/spec/callback.py +24 -0
- django_bolt/openapi/spec/components.py +72 -0
- django_bolt/openapi/spec/contact.py +21 -0
- django_bolt/openapi/spec/discriminator.py +25 -0
- django_bolt/openapi/spec/encoding.py +67 -0
- django_bolt/openapi/spec/enums.py +41 -0
- django_bolt/openapi/spec/example.py +36 -0
- django_bolt/openapi/spec/external_documentation.py +21 -0
- django_bolt/openapi/spec/header.py +132 -0
- django_bolt/openapi/spec/info.py +50 -0
- django_bolt/openapi/spec/license.py +28 -0
- django_bolt/openapi/spec/link.py +66 -0
- django_bolt/openapi/spec/media_type.py +51 -0
- django_bolt/openapi/spec/oauth_flow.py +36 -0
- django_bolt/openapi/spec/oauth_flows.py +28 -0
- django_bolt/openapi/spec/open_api.py +87 -0
- django_bolt/openapi/spec/operation.py +105 -0
- django_bolt/openapi/spec/parameter.py +147 -0
- django_bolt/openapi/spec/path_item.py +78 -0
- django_bolt/openapi/spec/paths.py +27 -0
- django_bolt/openapi/spec/reference.py +38 -0
- django_bolt/openapi/spec/request_body.py +38 -0
- django_bolt/openapi/spec/response.py +48 -0
- django_bolt/openapi/spec/responses.py +44 -0
- django_bolt/openapi/spec/schema.py +678 -0
- django_bolt/openapi/spec/security_requirement.py +28 -0
- django_bolt/openapi/spec/security_scheme.py +69 -0
- django_bolt/openapi/spec/server.py +34 -0
- django_bolt/openapi/spec/server_variable.py +32 -0
- django_bolt/openapi/spec/tag.py +32 -0
- django_bolt/openapi/spec/xml.py +44 -0
- django_bolt/pagination.py +669 -0
- django_bolt/param_functions.py +49 -0
- django_bolt/params.py +337 -0
- django_bolt/request_parsing.py +128 -0
- django_bolt/responses.py +214 -0
- django_bolt/router.py +48 -0
- django_bolt/serialization.py +193 -0
- django_bolt/status_codes.py +321 -0
- django_bolt/testing/__init__.py +10 -0
- django_bolt/testing/client.py +274 -0
- django_bolt/testing/helpers.py +93 -0
- django_bolt/tests/__init__.py +0 -0
- django_bolt/tests/admin_tests/__init__.py +1 -0
- django_bolt/tests/admin_tests/conftest.py +6 -0
- django_bolt/tests/admin_tests/test_admin_with_django.py +278 -0
- django_bolt/tests/admin_tests/urls.py +9 -0
- django_bolt/tests/cbv/__init__.py +0 -0
- django_bolt/tests/cbv/test_class_views.py +570 -0
- django_bolt/tests/cbv/test_class_views_django_orm.py +703 -0
- django_bolt/tests/cbv/test_class_views_features.py +1173 -0
- django_bolt/tests/cbv/test_class_views_with_client.py +622 -0
- django_bolt/tests/conftest.py +165 -0
- django_bolt/tests/test_action_decorator.py +399 -0
- django_bolt/tests/test_auth_secret_key.py +83 -0
- django_bolt/tests/test_decorator_syntax.py +159 -0
- django_bolt/tests/test_error_handling.py +481 -0
- django_bolt/tests/test_file_response.py +192 -0
- django_bolt/tests/test_global_cors.py +172 -0
- django_bolt/tests/test_guards_auth.py +441 -0
- django_bolt/tests/test_guards_integration.py +303 -0
- django_bolt/tests/test_health.py +283 -0
- django_bolt/tests/test_integration_validation.py +400 -0
- django_bolt/tests/test_json_validation.py +536 -0
- django_bolt/tests/test_jwt_auth.py +327 -0
- django_bolt/tests/test_jwt_token.py +458 -0
- django_bolt/tests/test_logging.py +837 -0
- django_bolt/tests/test_logging_merge.py +419 -0
- django_bolt/tests/test_middleware.py +492 -0
- django_bolt/tests/test_middleware_server.py +230 -0
- django_bolt/tests/test_model_viewset.py +323 -0
- django_bolt/tests/test_models.py +24 -0
- django_bolt/tests/test_pagination.py +1258 -0
- django_bolt/tests/test_parameter_validation.py +178 -0
- django_bolt/tests/test_syntax.py +626 -0
- django_bolt/tests/test_testing_utilities.py +163 -0
- django_bolt/tests/test_testing_utilities_simple.py +123 -0
- django_bolt/tests/test_viewset_unified.py +346 -0
- django_bolt/typing.py +273 -0
- django_bolt/views.py +1110 -0
- django_bolt-0.1.0.dist-info/METADATA +629 -0
- django_bolt-0.1.0.dist-info/RECORD +128 -0
- django_bolt-0.1.0.dist-info/WHEEL +4 -0
- 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()
|