drfast-api 0.1.0__py3-none-any.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.
- drfast/__init__.py +44 -0
- drfast/auth.py +126 -0
- drfast/cli.py +358 -0
- drfast/filters.py +36 -0
- drfast/middleware.py +48 -0
- drfast/models.py +45 -0
- drfast/orm.py +35 -0
- drfast/pagination.py +53 -0
- drfast/permissions.py +54 -0
- drfast/routers.py +42 -0
- drfast/serializers.py +214 -0
- drfast/settings.py +45 -0
- drfast/views.py +230 -0
- drfast_api-0.1.0.dist-info/METADATA +98 -0
- drfast_api-0.1.0.dist-info/RECORD +18 -0
- drfast_api-0.1.0.dist-info/WHEEL +4 -0
- drfast_api-0.1.0.dist-info/entry_points.txt +2 -0
- drfast_api-0.1.0.dist-info/licenses/LICENSE +21 -0
drfast/pagination.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Native JSON limit/offset pagination."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import HTTPException, Request, status
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LimitOffsetPagination:
|
|
11
|
+
default_limit = 50
|
|
12
|
+
max_limit = 100
|
|
13
|
+
|
|
14
|
+
def parameters(self, request: Request) -> tuple[int, int]:
|
|
15
|
+
try:
|
|
16
|
+
limit = int(request.query_params.get("limit", self.default_limit))
|
|
17
|
+
offset = int(request.query_params.get("offset", 0))
|
|
18
|
+
except ValueError as exc:
|
|
19
|
+
raise HTTPException(
|
|
20
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
21
|
+
detail="Invalid pagination.",
|
|
22
|
+
) from exc
|
|
23
|
+
if limit < 1 or limit > self.max_limit or offset < 0:
|
|
24
|
+
raise HTTPException(
|
|
25
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
26
|
+
detail="Invalid pagination.",
|
|
27
|
+
)
|
|
28
|
+
return limit, offset
|
|
29
|
+
|
|
30
|
+
def response(
|
|
31
|
+
self,
|
|
32
|
+
request: Request,
|
|
33
|
+
*,
|
|
34
|
+
count: int,
|
|
35
|
+
offset: int,
|
|
36
|
+
limit: int,
|
|
37
|
+
results: list[Any],
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
def link(next_offset: int) -> str | None:
|
|
40
|
+
if next_offset < 0 or next_offset >= count:
|
|
41
|
+
return None
|
|
42
|
+
params = dict(request.query_params)
|
|
43
|
+
params.update({"limit": str(limit), "offset": str(next_offset)})
|
|
44
|
+
query = "&".join(f"{key}={value}" for key, value in params.items())
|
|
45
|
+
return f"{request.url.path}?{query}"
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
"count": count,
|
|
49
|
+
"next": link(offset + limit),
|
|
50
|
+
"previous": link(max(offset - limit, -1)),
|
|
51
|
+
"results": results,
|
|
52
|
+
}
|
|
53
|
+
|
drfast/permissions.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Async DRF-style permission primitives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import HTTPException, Request, status
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BasePermission:
|
|
11
|
+
"""Base for request-level and object-level access rules."""
|
|
12
|
+
|
|
13
|
+
message = "You do not have permission to perform this action."
|
|
14
|
+
|
|
15
|
+
async def has_permission(self, request: Request, view: Any) -> bool:
|
|
16
|
+
return True
|
|
17
|
+
|
|
18
|
+
async def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool:
|
|
19
|
+
return await self.has_permission(request, view)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AllowAny(BasePermission):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class IsAuthenticated(BasePermission):
|
|
27
|
+
message = "Authentication credentials were not provided."
|
|
28
|
+
|
|
29
|
+
async def has_permission(self, request: Request, view: Any) -> bool:
|
|
30
|
+
user = getattr(request.state, "user", None)
|
|
31
|
+
return user is not None and bool(getattr(user, "is_active", True))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class IsAdminUser(IsAuthenticated):
|
|
35
|
+
message = "Administrator access is required."
|
|
36
|
+
|
|
37
|
+
async def has_permission(self, request: Request, view: Any) -> bool:
|
|
38
|
+
return await super().has_permission(request, view) and bool(
|
|
39
|
+
getattr(request.state.user, "is_superuser", False)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def enforce_permissions(request: Request, view: Any, obj: Any | None = None) -> None:
|
|
44
|
+
"""Raise a uniform HTTP error for failed configured permissions."""
|
|
45
|
+
for permission_type in getattr(view, "permission_classes", (AllowAny,)):
|
|
46
|
+
permission = permission_type()
|
|
47
|
+
allowed = (
|
|
48
|
+
await permission.has_object_permission(request, view, obj)
|
|
49
|
+
if obj is not None
|
|
50
|
+
else await permission.has_permission(request, view)
|
|
51
|
+
)
|
|
52
|
+
if not allowed:
|
|
53
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=permission.message)
|
|
54
|
+
|
drfast/routers.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Router registry for model viewsets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, FastAPI
|
|
8
|
+
|
|
9
|
+
from drfast.views import ModelViewSet
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DefaultRouter:
|
|
13
|
+
"""Register viewsets then mount their FastAPI routes explicitly."""
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self._registry: list[tuple[str, type[ModelViewSet], str | None]] = []
|
|
17
|
+
|
|
18
|
+
def register(
|
|
19
|
+
self,
|
|
20
|
+
prefix: str,
|
|
21
|
+
viewset: type[ModelViewSet],
|
|
22
|
+
*,
|
|
23
|
+
basename: str | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
self._registry.append((prefix.rstrip("/"), viewset, basename))
|
|
26
|
+
|
|
27
|
+
def urls(self) -> list[APIRouter]:
|
|
28
|
+
return [
|
|
29
|
+
viewset().as_router(prefix=prefix, tags=[basename or prefix])
|
|
30
|
+
for prefix, viewset, basename in self._registry
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
def include_in(self, app: FastAPI, *, prefix: str = "") -> None:
|
|
34
|
+
for router in self.urls():
|
|
35
|
+
app.include_router(router, prefix=prefix)
|
|
36
|
+
|
|
37
|
+
def routes(self) -> list[dict[str, Any]]:
|
|
38
|
+
return [
|
|
39
|
+
{"prefix": prefix, "viewset": viewset.__name__, "basename": basename}
|
|
40
|
+
for prefix, viewset, basename in self._registry
|
|
41
|
+
]
|
|
42
|
+
|
drfast/serializers.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""DRF-shaped serializers backed by Pydantic v2 validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any, ClassVar, Generic, TypeVar, cast
|
|
7
|
+
|
|
8
|
+
from pydantic import (
|
|
9
|
+
BaseModel,
|
|
10
|
+
ConfigDict,
|
|
11
|
+
EmailStr,
|
|
12
|
+
ValidationError,
|
|
13
|
+
create_model,
|
|
14
|
+
)
|
|
15
|
+
from pydantic import (
|
|
16
|
+
Field as PydanticField,
|
|
17
|
+
)
|
|
18
|
+
from tortoise.models import Model as TortoiseModel
|
|
19
|
+
|
|
20
|
+
ModelType = TypeVar("ModelType", bound=TortoiseModel)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SerializerValidationError(ValueError):
|
|
24
|
+
"""Validation failure with a JSON-ready error detail."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, detail: Any) -> None:
|
|
27
|
+
super().__init__("Serializer validation failed")
|
|
28
|
+
self.detail = detail
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Field:
|
|
32
|
+
"""A declarative serializer field.
|
|
33
|
+
|
|
34
|
+
The field's concrete Python type is passed to Pydantic, so FastAPI retains
|
|
35
|
+
native request validation and OpenAPI schemas.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
python_type: Any = Any
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
required: bool = True,
|
|
44
|
+
default: Any = ...,
|
|
45
|
+
read_only: bool = False,
|
|
46
|
+
write_only: bool = False,
|
|
47
|
+
alias: str | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
self.required = required
|
|
50
|
+
self.default = default
|
|
51
|
+
self.read_only = read_only
|
|
52
|
+
self.write_only = write_only
|
|
53
|
+
self.alias = alias
|
|
54
|
+
|
|
55
|
+
def pydantic_field(self, *, for_input: bool) -> tuple[Any, Any]:
|
|
56
|
+
if (for_input and self.read_only) or (not for_input and self.write_only):
|
|
57
|
+
return Any, PydanticField(exclude=True)
|
|
58
|
+
default = self.default
|
|
59
|
+
if not self.required and default is ...:
|
|
60
|
+
default = None
|
|
61
|
+
return self.python_type, PydanticField(default=default, alias=self.alias)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class CharField(Field):
|
|
65
|
+
python_type = str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class EmailField(Field):
|
|
69
|
+
python_type = EmailStr
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class IntegerField(Field):
|
|
73
|
+
python_type = int
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class BooleanField(Field):
|
|
77
|
+
python_type = bool
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class UUIDField(Field):
|
|
81
|
+
from uuid import UUID
|
|
82
|
+
|
|
83
|
+
python_type = UUID
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class SerializerMeta(type):
|
|
87
|
+
def __new__(
|
|
88
|
+
mcls: type[SerializerMeta],
|
|
89
|
+
name: str,
|
|
90
|
+
bases: tuple[type[Any], ...],
|
|
91
|
+
namespace: dict[str, Any],
|
|
92
|
+
) -> type[Serializer[Any]]:
|
|
93
|
+
declared: dict[str, Field] = {}
|
|
94
|
+
for base in bases:
|
|
95
|
+
declared.update(getattr(base, "_declared_fields", {}))
|
|
96
|
+
for key, value in list(namespace.items()):
|
|
97
|
+
if isinstance(value, Field):
|
|
98
|
+
declared[key] = value
|
|
99
|
+
namespace.pop(key)
|
|
100
|
+
namespace["_declared_fields"] = declared
|
|
101
|
+
return cast("type[Serializer[Any]]", super().__new__(mcls, name, bases, namespace))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Serializer(Generic[ModelType], metaclass=SerializerMeta):
|
|
105
|
+
"""Serializer with explicit fields and async validation hooks."""
|
|
106
|
+
|
|
107
|
+
_declared_fields: ClassVar[dict[str, Field]]
|
|
108
|
+
|
|
109
|
+
def __init__(self, instance: ModelType | None = None, *, partial: bool = False) -> None:
|
|
110
|
+
self.instance = instance
|
|
111
|
+
self.partial = partial
|
|
112
|
+
self.validated_data: dict[str, Any] = {}
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def _schema(cls, *, for_input: bool, partial: bool = False) -> type[BaseModel]:
|
|
116
|
+
fields: dict[str, tuple[Any, Any]] = {}
|
|
117
|
+
for name, field in cls._declared_fields.items():
|
|
118
|
+
if (for_input and field.read_only) or (not for_input and field.write_only):
|
|
119
|
+
continue
|
|
120
|
+
annotation, default = field.pydantic_field(for_input=for_input)
|
|
121
|
+
if partial and for_input and default is ...:
|
|
122
|
+
default = None
|
|
123
|
+
fields[name] = (annotation, default)
|
|
124
|
+
model = create_model(
|
|
125
|
+
f"{cls.__name__}{'Input' if for_input else 'Output'}",
|
|
126
|
+
__config__=ConfigDict(from_attributes=True, populate_by_name=True),
|
|
127
|
+
**cast(Any, fields),
|
|
128
|
+
)
|
|
129
|
+
return cast(type[BaseModel], model)
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def input_schema(cls) -> type[BaseModel]:
|
|
133
|
+
return cls._schema(for_input=True)
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def output_schema(cls) -> type[BaseModel]:
|
|
137
|
+
return cls._schema(for_input=False)
|
|
138
|
+
|
|
139
|
+
async def is_valid(self, data: Mapping[str, Any]) -> bool:
|
|
140
|
+
try:
|
|
141
|
+
parsed = self._schema(for_input=True, partial=self.partial).model_validate(data)
|
|
142
|
+
except ValidationError as exc:
|
|
143
|
+
raise SerializerValidationError(exc.errors(include_url=False)) from exc
|
|
144
|
+
values = parsed.model_dump(exclude_unset=self.partial, by_alias=False)
|
|
145
|
+
for name, value in list(values.items()):
|
|
146
|
+
validator = getattr(self, f"validate_{name}", None)
|
|
147
|
+
if validator is not None:
|
|
148
|
+
result = validator(value)
|
|
149
|
+
values[name] = await result if hasattr(result, "__await__") else result
|
|
150
|
+
validated = self.validate(values)
|
|
151
|
+
self.validated_data = await validated if hasattr(validated, "__await__") else validated
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
|
|
155
|
+
return attrs
|
|
156
|
+
|
|
157
|
+
async def create(self, validated_data: dict[str, Any]) -> ModelType:
|
|
158
|
+
raise NotImplementedError("Serializer.create() must be implemented")
|
|
159
|
+
|
|
160
|
+
async def update(self, instance: ModelType, validated_data: dict[str, Any]) -> ModelType:
|
|
161
|
+
raise NotImplementedError("Serializer.update() must be implemented")
|
|
162
|
+
|
|
163
|
+
async def save(self) -> ModelType:
|
|
164
|
+
if self.instance is None:
|
|
165
|
+
self.instance = await self.create(self.validated_data)
|
|
166
|
+
else:
|
|
167
|
+
self.instance = await self.update(self.instance, self.validated_data)
|
|
168
|
+
return self.instance
|
|
169
|
+
|
|
170
|
+
def representation(self, instance: ModelType | Mapping[str, Any]) -> dict[str, Any]:
|
|
171
|
+
data = self.output_schema().model_validate(instance).model_dump(mode="json", by_alias=True)
|
|
172
|
+
return {name: value for name, value in data.items() if value is not None}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class ModelSerializer(Serializer[ModelType]):
|
|
176
|
+
"""Serializer with model-backed create and update behavior."""
|
|
177
|
+
|
|
178
|
+
model: ClassVar[type[ModelType]]
|
|
179
|
+
fields: ClassVar[tuple[str, ...] | str] = "__all__"
|
|
180
|
+
read_only_fields: ClassVar[tuple[str, ...]] = ()
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def _model_fields(cls) -> dict[str, Field]:
|
|
184
|
+
if cls._declared_fields:
|
|
185
|
+
return cls._declared_fields
|
|
186
|
+
result: dict[str, Field] = {}
|
|
187
|
+
selected = None if cls.fields == "__all__" else set(cls.fields)
|
|
188
|
+
for name, db_field in cls.model._meta.fields_map.items():
|
|
189
|
+
if selected is not None and name not in selected:
|
|
190
|
+
continue
|
|
191
|
+
is_automatic = name in {"id", "created_at", "updated_at", "deleted_at"}
|
|
192
|
+
python_type = getattr(db_field, "field_type", Any)
|
|
193
|
+
result[name] = Field(
|
|
194
|
+
required=not getattr(db_field, "null", False),
|
|
195
|
+
read_only=is_automatic or name in cls.read_only_fields,
|
|
196
|
+
)
|
|
197
|
+
result[name].python_type = python_type
|
|
198
|
+
return result
|
|
199
|
+
|
|
200
|
+
@classmethod
|
|
201
|
+
def _schema(cls, *, for_input: bool, partial: bool = False) -> type[BaseModel]:
|
|
202
|
+
if not cls._declared_fields:
|
|
203
|
+
cls._declared_fields = cls._model_fields()
|
|
204
|
+
return super()._schema(for_input=for_input, partial=partial)
|
|
205
|
+
|
|
206
|
+
async def create(self, validated_data: dict[str, Any]) -> ModelType:
|
|
207
|
+
return await self.model.create(**validated_data)
|
|
208
|
+
|
|
209
|
+
async def update(self, instance: ModelType, validated_data: dict[str, Any]) -> ModelType:
|
|
210
|
+
for name, value in validated_data.items():
|
|
211
|
+
setattr(instance, name, value)
|
|
212
|
+
await instance.save(update_fields=list(validated_data))
|
|
213
|
+
return instance
|
|
214
|
+
|
drfast/settings.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Typed application settings and Tortoise configuration generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import Field, SecretStr
|
|
8
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DRFastSettings(BaseSettings):
|
|
12
|
+
"""Settings consumed by project wiring, never by views directly."""
|
|
13
|
+
|
|
14
|
+
model_config = SettingsConfigDict(env_file=".env", env_prefix="", extra="ignore")
|
|
15
|
+
|
|
16
|
+
app_name: str = "DRFast API"
|
|
17
|
+
debug: bool = False
|
|
18
|
+
database_url: str = "sqlite://db.sqlite3"
|
|
19
|
+
installed_apps: list[str] = Field(default_factory=list)
|
|
20
|
+
secret_key: SecretStr = SecretStr("change-me-in-production")
|
|
21
|
+
jwt_lifetime_seconds: int = 3600
|
|
22
|
+
cors_origins: list[str] = Field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
def tortoise_config(self) -> dict[str, Any]:
|
|
25
|
+
"""Return the configuration shared by runtime and migration commands."""
|
|
26
|
+
apps: dict[str, dict[str, Any]] = {}
|
|
27
|
+
for module in self.installed_apps:
|
|
28
|
+
package, _, _ = module.rpartition(".")
|
|
29
|
+
label = package.rsplit(".", maxsplit=1)[-1]
|
|
30
|
+
if label in apps:
|
|
31
|
+
raise ValueError(f"Duplicate Tortoise app label '{label}' in installed_apps")
|
|
32
|
+
apps[label] = {
|
|
33
|
+
"models": [module],
|
|
34
|
+
"default_connection": "default",
|
|
35
|
+
"migrations": f"{package}.migrations",
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
"connections": {"default": self.database_url},
|
|
39
|
+
"apps": apps,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def validate_production(self) -> None:
|
|
43
|
+
if not self.debug and self.secret_key.get_secret_value() == "change-me-in-production":
|
|
44
|
+
raise ValueError("SECRET_KEY must be set when DEBUG is false")
|
|
45
|
+
|
drfast/views.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Class-based FastAPI views and Tortoise model viewsets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Callable, Sequence
|
|
7
|
+
from typing import Any, ClassVar, cast
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, Body, Depends, HTTPException, Request, status
|
|
11
|
+
from tortoise.exceptions import DoesNotExist
|
|
12
|
+
from tortoise.models import Model as TortoiseModel
|
|
13
|
+
|
|
14
|
+
from drfast.filters import FilterSet
|
|
15
|
+
from drfast.pagination import LimitOffsetPagination
|
|
16
|
+
from drfast.permissions import AllowAny, BasePermission, enforce_permissions
|
|
17
|
+
from drfast.serializers import ModelSerializer, SerializerValidationError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class APIView:
|
|
21
|
+
"""Base class for a FastAPI route group with DRF-style permissions."""
|
|
22
|
+
|
|
23
|
+
permission_classes: ClassVar[tuple[type[BasePermission], ...]] = (AllowAny,)
|
|
24
|
+
authentication_dependencies: ClassVar[tuple[Callable[..., Any], ...]] = ()
|
|
25
|
+
|
|
26
|
+
async def check_permissions(self, request: Request, obj: Any | None = None) -> None:
|
|
27
|
+
await enforce_permissions(request, self, obj)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ModelViewSet(APIView):
|
|
31
|
+
"""Async CRUD viewset for a Tortoise model."""
|
|
32
|
+
|
|
33
|
+
model: ClassVar[type[TortoiseModel]]
|
|
34
|
+
serializer_class: ClassVar[type[ModelSerializer[Any]]]
|
|
35
|
+
filterset_class: ClassVar[type[FilterSet] | None] = None
|
|
36
|
+
pagination_class: ClassVar[type[LimitOffsetPagination] | None] = LimitOffsetPagination
|
|
37
|
+
ordering_fields: ClassVar[tuple[str, ...]] = ()
|
|
38
|
+
|
|
39
|
+
def serializer(
|
|
40
|
+
self,
|
|
41
|
+
instance: Any | None = None,
|
|
42
|
+
*,
|
|
43
|
+
partial: bool = False,
|
|
44
|
+
) -> ModelSerializer[Any]:
|
|
45
|
+
return self.serializer_class(instance, partial=partial)
|
|
46
|
+
|
|
47
|
+
async def get_object(self, request: Request, pk: UUID) -> TortoiseModel:
|
|
48
|
+
try:
|
|
49
|
+
obj = await self.model.get(id=pk)
|
|
50
|
+
except DoesNotExist as exc:
|
|
51
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found.") from exc
|
|
52
|
+
await self.check_permissions(request, obj)
|
|
53
|
+
return obj
|
|
54
|
+
|
|
55
|
+
def _queryset(self, request: Request) -> Any:
|
|
56
|
+
queryset = self.model.all()
|
|
57
|
+
if self.filterset_class is not None:
|
|
58
|
+
queryset = queryset.filter(**self.filterset_class.filters_from_request(request))
|
|
59
|
+
ordering = request.query_params.get("ordering")
|
|
60
|
+
if ordering:
|
|
61
|
+
fields = tuple(field.strip() for field in ordering.split(",") if field.strip())
|
|
62
|
+
if not fields or any(
|
|
63
|
+
field.removeprefix("-") not in self.ordering_fields for field in fields
|
|
64
|
+
):
|
|
65
|
+
raise HTTPException(
|
|
66
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
67
|
+
detail="Invalid ordering.",
|
|
68
|
+
)
|
|
69
|
+
queryset = queryset.order_by(*fields)
|
|
70
|
+
return queryset
|
|
71
|
+
|
|
72
|
+
async def list(self, request: Request) -> dict[str, Any] | list[dict[str, Any]]:
|
|
73
|
+
await self.check_permissions(request)
|
|
74
|
+
queryset = self._queryset(request)
|
|
75
|
+
if self.pagination_class is None:
|
|
76
|
+
instances = await queryset
|
|
77
|
+
return [self.serializer(instance).representation(instance) for instance in instances]
|
|
78
|
+
paginator = self.pagination_class()
|
|
79
|
+
limit, offset = paginator.parameters(request)
|
|
80
|
+
count = await queryset.count()
|
|
81
|
+
instances = await queryset.offset(offset).limit(limit)
|
|
82
|
+
return paginator.response(
|
|
83
|
+
request,
|
|
84
|
+
count=count,
|
|
85
|
+
offset=offset,
|
|
86
|
+
limit=limit,
|
|
87
|
+
results=[
|
|
88
|
+
self.serializer(instance).representation(instance) for instance in instances
|
|
89
|
+
],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
async def retrieve(self, request: Request, pk: UUID) -> dict[str, Any]:
|
|
93
|
+
instance = await self.get_object(request, pk)
|
|
94
|
+
return self.serializer(instance).representation(instance)
|
|
95
|
+
|
|
96
|
+
async def create(self, request: Request, payload: dict[str, Any]) -> dict[str, Any]:
|
|
97
|
+
await self.check_permissions(request)
|
|
98
|
+
serializer = self.serializer()
|
|
99
|
+
try:
|
|
100
|
+
await serializer.is_valid(payload)
|
|
101
|
+
except SerializerValidationError as exc:
|
|
102
|
+
raise HTTPException(
|
|
103
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
104
|
+
detail=exc.detail,
|
|
105
|
+
) from exc
|
|
106
|
+
instance = await serializer.save()
|
|
107
|
+
return serializer.representation(instance)
|
|
108
|
+
|
|
109
|
+
async def update(
|
|
110
|
+
self,
|
|
111
|
+
request: Request,
|
|
112
|
+
pk: UUID,
|
|
113
|
+
payload: dict[str, Any],
|
|
114
|
+
*,
|
|
115
|
+
partial: bool,
|
|
116
|
+
) -> dict[str, Any]:
|
|
117
|
+
instance = await self.get_object(request, pk)
|
|
118
|
+
serializer = self.serializer(instance, partial=partial)
|
|
119
|
+
try:
|
|
120
|
+
await serializer.is_valid(payload)
|
|
121
|
+
except SerializerValidationError as exc:
|
|
122
|
+
raise HTTPException(
|
|
123
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
124
|
+
detail=exc.detail,
|
|
125
|
+
) from exc
|
|
126
|
+
instance = await serializer.save()
|
|
127
|
+
return serializer.representation(instance)
|
|
128
|
+
|
|
129
|
+
async def destroy(self, request: Request, pk: UUID) -> None:
|
|
130
|
+
instance = await self.get_object(request, pk)
|
|
131
|
+
await instance.delete()
|
|
132
|
+
|
|
133
|
+
def as_router(self, *, prefix: str, tags: Sequence[str] | None = None) -> APIRouter:
|
|
134
|
+
"""Build conventional collection/detail routes for this viewset."""
|
|
135
|
+
router = APIRouter(prefix=prefix, tags=[*tags] if tags else [])
|
|
136
|
+
input_schema = self.serializer_class.input_schema()
|
|
137
|
+
partial_input_schema = self.serializer_class._schema(for_input=True, partial=True)
|
|
138
|
+
output_schema = self.serializer_class.output_schema()
|
|
139
|
+
dependencies = [Depends(dependency) for dependency in self.authentication_dependencies]
|
|
140
|
+
|
|
141
|
+
@router.get("/", response_model=None, dependencies=dependencies)
|
|
142
|
+
async def list_view(request: Request) -> Any:
|
|
143
|
+
return await self.list(request)
|
|
144
|
+
|
|
145
|
+
@router.get("/{pk}", response_model=output_schema, dependencies=dependencies)
|
|
146
|
+
async def retrieve_view(request: Request, pk: UUID) -> Any:
|
|
147
|
+
return await self.retrieve(request, pk)
|
|
148
|
+
|
|
149
|
+
async def create_view(request: Request, payload: Any) -> Any:
|
|
150
|
+
return await self.create(request, payload.model_dump(exclude_unset=True))
|
|
151
|
+
|
|
152
|
+
async def update_view(request: Request, pk: UUID, payload: Any) -> Any:
|
|
153
|
+
return await self.update(
|
|
154
|
+
request, pk, payload.model_dump(exclude_unset=True), partial=False
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
async def partial_update_view(request: Request, pk: UUID, payload: Any) -> Any:
|
|
158
|
+
return await self.update(
|
|
159
|
+
request, pk, payload.model_dump(exclude_unset=True), partial=True
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def signature(*parameters: inspect.Parameter) -> inspect.Signature:
|
|
163
|
+
return inspect.Signature(parameters)
|
|
164
|
+
|
|
165
|
+
request_parameter = inspect.Parameter(
|
|
166
|
+
"request", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Request
|
|
167
|
+
)
|
|
168
|
+
pk_parameter = inspect.Parameter(
|
|
169
|
+
"pk", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=UUID
|
|
170
|
+
)
|
|
171
|
+
cast(Any, create_view).__signature__ = signature(
|
|
172
|
+
request_parameter,
|
|
173
|
+
inspect.Parameter(
|
|
174
|
+
"payload",
|
|
175
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
176
|
+
default=Body(...),
|
|
177
|
+
annotation=input_schema,
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
cast(Any, update_view).__signature__ = signature(
|
|
181
|
+
request_parameter,
|
|
182
|
+
pk_parameter,
|
|
183
|
+
inspect.Parameter(
|
|
184
|
+
"payload",
|
|
185
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
186
|
+
default=Body(...),
|
|
187
|
+
annotation=input_schema,
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
cast(Any, partial_update_view).__signature__ = signature(
|
|
191
|
+
request_parameter,
|
|
192
|
+
pk_parameter,
|
|
193
|
+
inspect.Parameter(
|
|
194
|
+
"payload",
|
|
195
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
196
|
+
default=Body(...),
|
|
197
|
+
annotation=partial_input_schema,
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
router.add_api_route(
|
|
201
|
+
"/",
|
|
202
|
+
create_view,
|
|
203
|
+
methods=["POST"],
|
|
204
|
+
response_model=output_schema,
|
|
205
|
+
status_code=status.HTTP_201_CREATED,
|
|
206
|
+
dependencies=dependencies,
|
|
207
|
+
)
|
|
208
|
+
router.add_api_route(
|
|
209
|
+
"/{pk}",
|
|
210
|
+
update_view,
|
|
211
|
+
methods=["PUT"],
|
|
212
|
+
response_model=output_schema,
|
|
213
|
+
dependencies=dependencies,
|
|
214
|
+
)
|
|
215
|
+
router.add_api_route(
|
|
216
|
+
"/{pk}",
|
|
217
|
+
partial_update_view,
|
|
218
|
+
methods=["PATCH"],
|
|
219
|
+
response_model=output_schema,
|
|
220
|
+
dependencies=dependencies,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
@router.delete(
|
|
224
|
+
"/{pk}", status_code=status.HTTP_204_NO_CONTENT, dependencies=dependencies
|
|
225
|
+
)
|
|
226
|
+
async def destroy_view(request: Request, pk: UUID) -> None:
|
|
227
|
+
await self.destroy(request, pk)
|
|
228
|
+
|
|
229
|
+
return router
|
|
230
|
+
|