swiftpy-framework 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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, get_args, get_origin
|
|
5
|
+
|
|
6
|
+
from swiftpy.database.orm.model import Model
|
|
7
|
+
from swiftpy.database.orm.relationships import (
|
|
8
|
+
BelongsTo,
|
|
9
|
+
BelongsToMany,
|
|
10
|
+
HasMany,
|
|
11
|
+
HasOne,
|
|
12
|
+
Relation,
|
|
13
|
+
)
|
|
14
|
+
from swiftpy.support.strings import pluralize, snake_case
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from swiftpy.admin.auth import AdminUser
|
|
18
|
+
from swiftpy.core.application import Application
|
|
19
|
+
from swiftpy.database.orm.model import ModelQueryBuilder
|
|
20
|
+
from swiftpy.http.request import Request
|
|
21
|
+
from swiftpy.http.router import Router
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T", bound=Model)
|
|
24
|
+
A = TypeVar("A", bound="ModelAdmin")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def humanize_name(name: str) -> str:
|
|
28
|
+
"""Convert snake_case column or identifier to title case label."""
|
|
29
|
+
if name.lower() == "id":
|
|
30
|
+
return "ID"
|
|
31
|
+
clean = name.removesuffix("_id") if name.endswith("_id") and len(name) > 3 else name
|
|
32
|
+
return clean.replace("_", " ").title()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_humanize_name = humanize_name
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _infer_type_name(hint: Any) -> str:
|
|
39
|
+
"""Infer primitive string type name from a Python type annotation."""
|
|
40
|
+
if hint is None or hint is type(None):
|
|
41
|
+
return "str"
|
|
42
|
+
|
|
43
|
+
if isinstance(hint, str):
|
|
44
|
+
norm = hint.strip().lower()
|
|
45
|
+
if "bool" in norm:
|
|
46
|
+
return "bool"
|
|
47
|
+
if "int" in norm:
|
|
48
|
+
return "int"
|
|
49
|
+
if "float" in norm or "decimal" in norm:
|
|
50
|
+
return "float"
|
|
51
|
+
if "datetime" in norm or "date" in norm:
|
|
52
|
+
return "datetime"
|
|
53
|
+
if "dict" in norm or "mapping" in norm:
|
|
54
|
+
return "dict"
|
|
55
|
+
if "list" in norm or "sequence" in norm or "set" in norm or "tuple" in norm:
|
|
56
|
+
return "list"
|
|
57
|
+
return "str"
|
|
58
|
+
|
|
59
|
+
origin = get_origin(hint)
|
|
60
|
+
if origin is not None:
|
|
61
|
+
args = get_args(hint)
|
|
62
|
+
# Handle Optional[X] or Union[X, None]
|
|
63
|
+
non_none_args = [a for a in args if a is not type(None)]
|
|
64
|
+
if non_none_args:
|
|
65
|
+
return _infer_type_name(non_none_args[0])
|
|
66
|
+
return "str"
|
|
67
|
+
|
|
68
|
+
if isinstance(hint, type):
|
|
69
|
+
name = hint.__name__.lower()
|
|
70
|
+
if name in ("int", "integer"):
|
|
71
|
+
return "int"
|
|
72
|
+
if name in ("float", "decimal"):
|
|
73
|
+
return "float"
|
|
74
|
+
if name in ("bool", "boolean"):
|
|
75
|
+
return "bool"
|
|
76
|
+
if name in ("datetime", "date", "time"):
|
|
77
|
+
return "datetime"
|
|
78
|
+
if name in ("dict", "mapping"):
|
|
79
|
+
return "dict"
|
|
80
|
+
if name in ("list", "sequence", "set", "tuple"):
|
|
81
|
+
return "list"
|
|
82
|
+
return "str"
|
|
83
|
+
|
|
84
|
+
return "str"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class FieldMetadata:
|
|
88
|
+
"""Metadata describing a declared field on an Active Record Model."""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
name: str,
|
|
93
|
+
type_hint: Any,
|
|
94
|
+
nullable: bool = True,
|
|
95
|
+
default: Any = None,
|
|
96
|
+
is_primary_key: bool = False,
|
|
97
|
+
is_foreign_key: bool = False,
|
|
98
|
+
foreign_relation_name: str | None = None,
|
|
99
|
+
foreign_model_name: str | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
self.name = name
|
|
102
|
+
self.type_hint = type_hint
|
|
103
|
+
self.type_name = _infer_type_name(type_hint)
|
|
104
|
+
self.nullable = nullable
|
|
105
|
+
self.default = default
|
|
106
|
+
self.is_primary_key = is_primary_key
|
|
107
|
+
self.is_foreign_key = is_foreign_key
|
|
108
|
+
self.foreign_relation_name = foreign_relation_name
|
|
109
|
+
self.foreign_model_name = foreign_model_name
|
|
110
|
+
self.label = humanize_name(name)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def input_type(self) -> str:
|
|
114
|
+
if self.is_foreign_key:
|
|
115
|
+
return "select"
|
|
116
|
+
if self.type_name == "int":
|
|
117
|
+
return "number"
|
|
118
|
+
if self.type_name == "float":
|
|
119
|
+
return "number"
|
|
120
|
+
if self.type_name == "bool":
|
|
121
|
+
return "checkbox"
|
|
122
|
+
if self.type_name == "datetime":
|
|
123
|
+
return "datetime-local"
|
|
124
|
+
if self.name in ("content", "description", "body", "bio", "notes", "text"):
|
|
125
|
+
return "textarea"
|
|
126
|
+
return "text"
|
|
127
|
+
|
|
128
|
+
def coerce_value(self, raw_value: Any) -> Any:
|
|
129
|
+
"""Coerce raw HTTP form/JSON input to the declared field type."""
|
|
130
|
+
if self.type_name == "bool":
|
|
131
|
+
if isinstance(raw_value, bool):
|
|
132
|
+
return raw_value
|
|
133
|
+
if raw_value is None or raw_value == "":
|
|
134
|
+
return False
|
|
135
|
+
norm = str(raw_value).strip().lower()
|
|
136
|
+
return norm in ("true", "1", "yes", "on")
|
|
137
|
+
|
|
138
|
+
if raw_value is None or raw_value == "":
|
|
139
|
+
if self.default is not None:
|
|
140
|
+
return self.default
|
|
141
|
+
if self.nullable:
|
|
142
|
+
return None
|
|
143
|
+
if self.type_name == "str":
|
|
144
|
+
return ""
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
if self.type_name == "int":
|
|
148
|
+
try:
|
|
149
|
+
return int(raw_value)
|
|
150
|
+
except (ValueError, TypeError) as exc:
|
|
151
|
+
raise ValueError(f"Field '{self.name}' must be a valid integer.") from exc
|
|
152
|
+
|
|
153
|
+
if self.type_name == "float":
|
|
154
|
+
try:
|
|
155
|
+
return float(raw_value)
|
|
156
|
+
except (ValueError, TypeError) as exc:
|
|
157
|
+
raise ValueError(f"Field '{self.name}' must be a valid number.") from exc
|
|
158
|
+
|
|
159
|
+
return str(raw_value)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class RelationshipMetadata:
|
|
163
|
+
"""Metadata describing a declared relationship on an Active Record Model."""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
name: str,
|
|
168
|
+
relation_obj: Relation[Any],
|
|
169
|
+
owner_cls: type[Model],
|
|
170
|
+
) -> None:
|
|
171
|
+
self.name = name
|
|
172
|
+
self.relation_obj = relation_obj
|
|
173
|
+
self.owner_cls = owner_cls
|
|
174
|
+
self.foreign_key = relation_obj.foreign_key or f"{snake_case(owner_cls.__name__)}_id"
|
|
175
|
+
if isinstance(relation_obj, BelongsTo):
|
|
176
|
+
target = self.get_target_model() if relation_obj.local_key is None else None
|
|
177
|
+
self.local_key = (
|
|
178
|
+
relation_obj.local_key
|
|
179
|
+
or getattr(target, "__primary_key__", None)
|
|
180
|
+
or getattr(owner_cls, "__primary_key__", "id")
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
self.local_key = relation_obj.local_key or getattr(owner_cls, "__primary_key__", "id")
|
|
184
|
+
|
|
185
|
+
if isinstance(relation_obj, BelongsTo):
|
|
186
|
+
self.relation_type = "BelongsTo"
|
|
187
|
+
elif isinstance(relation_obj, HasMany):
|
|
188
|
+
self.relation_type = "HasMany"
|
|
189
|
+
elif isinstance(relation_obj, HasOne):
|
|
190
|
+
self.relation_type = "HasOne"
|
|
191
|
+
elif isinstance(relation_obj, BelongsToMany):
|
|
192
|
+
self.relation_type = "BelongsToMany"
|
|
193
|
+
else:
|
|
194
|
+
self.relation_type = "Relation"
|
|
195
|
+
|
|
196
|
+
if isinstance(relation_obj, BelongsToMany):
|
|
197
|
+
self.pivot_table = getattr(relation_obj, "pivot_table", None)
|
|
198
|
+
else:
|
|
199
|
+
self.pivot_table = None
|
|
200
|
+
|
|
201
|
+
self.target_model_name = relation_obj.related_model_name
|
|
202
|
+
self.label = humanize_name(name)
|
|
203
|
+
|
|
204
|
+
def get_target_model(self) -> type[Model] | None:
|
|
205
|
+
"""Resolve the target Model class, or return None if unresolved."""
|
|
206
|
+
try:
|
|
207
|
+
return self.relation_obj.get_target_model()
|
|
208
|
+
except (KeyError, ValueError, AttributeError, LookupError, RuntimeError):
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class ModelAdmin:
|
|
213
|
+
"""
|
|
214
|
+
Administrative configuration for a registered Model.
|
|
215
|
+
Controls list display columns, search fields, ordering, RBAC permissions, and CRUD form behaviors.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
model: type[Model]
|
|
219
|
+
list_display: ClassVar[list[str] | tuple[str, ...] | None] = None
|
|
220
|
+
search_fields: ClassVar[list[str]] = []
|
|
221
|
+
ordering: ClassVar[str | None] = None
|
|
222
|
+
readonly_fields: ClassVar[list[str]] = []
|
|
223
|
+
exclude: ClassVar[list[str]] = []
|
|
224
|
+
per_page: ClassVar[int] = 25
|
|
225
|
+
can_create: ClassVar[bool] = True
|
|
226
|
+
can_edit: ClassVar[bool] = True
|
|
227
|
+
can_delete: ClassVar[bool] = True
|
|
228
|
+
required_view_permission: ClassVar[str | None] = None
|
|
229
|
+
required_create_permission: ClassVar[str | None] = None
|
|
230
|
+
required_edit_permission: ClassVar[str | None] = None
|
|
231
|
+
required_delete_permission: ClassVar[str | None] = None
|
|
232
|
+
|
|
233
|
+
def __init__(self, model: type[Model], site: AdminSite | None = None) -> None:
|
|
234
|
+
self.model = model
|
|
235
|
+
self.site = site
|
|
236
|
+
self._fields_cache: list[FieldMetadata] | None = None
|
|
237
|
+
self._relations_cache: dict[str, RelationshipMetadata] | None = None
|
|
238
|
+
|
|
239
|
+
def __call__(self, admin_cls: type[ModelAdmin]) -> type[ModelAdmin]:
|
|
240
|
+
"""Support decorator usage: @admin.register(Model) class MyAdmin(ModelAdmin): ..."""
|
|
241
|
+
if (
|
|
242
|
+
isinstance(admin_cls, type)
|
|
243
|
+
and issubclass(admin_cls, ModelAdmin)
|
|
244
|
+
and self.site is not None
|
|
245
|
+
):
|
|
246
|
+
self.site.unregister(self.model)
|
|
247
|
+
instance = admin_cls(self.model, site=self.site)
|
|
248
|
+
self.site._register_model_admin(self.model, instance)
|
|
249
|
+
return admin_cls
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def primary_key(self) -> str:
|
|
253
|
+
"""Declared primary key column name on the underlying model."""
|
|
254
|
+
return str(getattr(self.model, "__primary_key__", "id"))
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def slug(self) -> str:
|
|
258
|
+
"""URL slug for this model in the admin panel."""
|
|
259
|
+
raw_table = getattr(self.model, "__table__", None)
|
|
260
|
+
if raw_table and isinstance(raw_table, str):
|
|
261
|
+
return raw_table.lower()
|
|
262
|
+
return pluralize(snake_case(self.model.__name__)).lower()
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def title(self) -> str:
|
|
266
|
+
"""Singular humanized display name."""
|
|
267
|
+
return humanize_name(snake_case(self.model.__name__))
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def plural_title(self) -> str:
|
|
271
|
+
"""Plural humanized display name."""
|
|
272
|
+
return pluralize(self.title)
|
|
273
|
+
|
|
274
|
+
def get_relationships(self) -> dict[str, RelationshipMetadata]:
|
|
275
|
+
"""Inspect and return declared relationships on the model."""
|
|
276
|
+
if self._relations_cache is not None:
|
|
277
|
+
return self._relations_cache
|
|
278
|
+
|
|
279
|
+
relations_config = getattr(self.model, "__relations_config__", {})
|
|
280
|
+
result: dict[str, RelationshipMetadata] = {}
|
|
281
|
+
for rel_name, rel_obj in relations_config.items():
|
|
282
|
+
result[rel_name] = RelationshipMetadata(
|
|
283
|
+
name=rel_name,
|
|
284
|
+
relation_obj=rel_obj,
|
|
285
|
+
owner_cls=self.model,
|
|
286
|
+
)
|
|
287
|
+
self._relations_cache = result
|
|
288
|
+
return result
|
|
289
|
+
|
|
290
|
+
def get_fields(self) -> list[FieldMetadata]:
|
|
291
|
+
"""Inspect and return declared fields on the model with relationship mapping."""
|
|
292
|
+
if self._fields_cache is not None:
|
|
293
|
+
return self._fields_cache
|
|
294
|
+
|
|
295
|
+
declared_fields = getattr(self.model, "__fields__", {})
|
|
296
|
+
defaults = getattr(self.model, "__field_defaults__", {})
|
|
297
|
+
pk = getattr(self.model, "__primary_key__", "id")
|
|
298
|
+
relationships = self.get_relationships()
|
|
299
|
+
|
|
300
|
+
# Build reverse lookup of foreign_key -> (relation_name, target_model_name)
|
|
301
|
+
fk_map: dict[str, tuple[str, str]] = {}
|
|
302
|
+
for rel_name, rel_meta in relationships.items():
|
|
303
|
+
if rel_meta.relation_type == "BelongsTo" and rel_meta.foreign_key:
|
|
304
|
+
fk_map[rel_meta.foreign_key] = (rel_name, rel_meta.target_model_name)
|
|
305
|
+
|
|
306
|
+
fields: list[FieldMetadata] = []
|
|
307
|
+
for field_name, type_hint in declared_fields.items():
|
|
308
|
+
is_pk = field_name == pk
|
|
309
|
+
is_fk = field_name in fk_map
|
|
310
|
+
rel_info = fk_map.get(field_name)
|
|
311
|
+
foreign_rel_name: str | None = rel_info[0] if rel_info else None
|
|
312
|
+
foreign_target_name: str | None = rel_info[1] if rel_info else None
|
|
313
|
+
def_val = defaults.get(field_name)
|
|
314
|
+
|
|
315
|
+
fields.append(
|
|
316
|
+
FieldMetadata(
|
|
317
|
+
name=field_name,
|
|
318
|
+
type_hint=type_hint,
|
|
319
|
+
nullable=is_pk or field_name in defaults,
|
|
320
|
+
default=def_val,
|
|
321
|
+
is_primary_key=is_pk,
|
|
322
|
+
is_foreign_key=is_fk,
|
|
323
|
+
foreign_relation_name=foreign_rel_name,
|
|
324
|
+
foreign_model_name=foreign_target_name,
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
self._fields_cache = fields
|
|
329
|
+
return fields
|
|
330
|
+
|
|
331
|
+
def get_field(self, name: str) -> FieldMetadata | None:
|
|
332
|
+
for f in self.get_fields():
|
|
333
|
+
if f.name == name:
|
|
334
|
+
return f
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
def get_list_display(self) -> list[str]:
|
|
338
|
+
"""Columns to display in the list view table."""
|
|
339
|
+
if self.list_display:
|
|
340
|
+
return list(self.list_display)
|
|
341
|
+
all_field_names = [f.name for f in self.get_fields()]
|
|
342
|
+
# Show primary key and first few fields
|
|
343
|
+
pk = getattr(self.model, "__primary_key__", "id")
|
|
344
|
+
cols = [pk] if pk in all_field_names else []
|
|
345
|
+
for f in all_field_names:
|
|
346
|
+
if f != pk and f not in cols:
|
|
347
|
+
cols.append(f)
|
|
348
|
+
if len(cols) >= 6:
|
|
349
|
+
break
|
|
350
|
+
return cols
|
|
351
|
+
|
|
352
|
+
def get_form_fields(self) -> list[FieldMetadata]:
|
|
353
|
+
"""Fields to include in Create and Edit forms."""
|
|
354
|
+
pk = getattr(self.model, "__primary_key__", "id")
|
|
355
|
+
excluded = set(self.exclude)
|
|
356
|
+
return [
|
|
357
|
+
f
|
|
358
|
+
for f in self.get_fields()
|
|
359
|
+
if not f.is_primary_key and f.name != pk and f.name not in excluded
|
|
360
|
+
]
|
|
361
|
+
|
|
362
|
+
def _has_action_permission(
|
|
363
|
+
self,
|
|
364
|
+
user: AdminUser,
|
|
365
|
+
explicit_perm: str | None,
|
|
366
|
+
default_perms: tuple[str, ...],
|
|
367
|
+
*,
|
|
368
|
+
action_enabled: bool = True,
|
|
369
|
+
) -> bool:
|
|
370
|
+
if not action_enabled or user.is_admin is not True:
|
|
371
|
+
return False
|
|
372
|
+
if explicit_perm is not None:
|
|
373
|
+
return user.has_permission(explicit_perm)
|
|
374
|
+
return any(user.has_permission(p) for p in default_perms)
|
|
375
|
+
|
|
376
|
+
def has_view_permission(self, request: Request, user: AdminUser) -> bool:
|
|
377
|
+
"""Return whether `user` is permitted to view records for this model."""
|
|
378
|
+
return self._has_action_permission(
|
|
379
|
+
user,
|
|
380
|
+
self.required_view_permission,
|
|
381
|
+
("all", "models:read", f"{self.slug}:read"),
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
def has_create_permission(self, request: Request, user: AdminUser) -> bool:
|
|
385
|
+
"""Return whether `user` is permitted to create records for this model."""
|
|
386
|
+
return self._has_action_permission(
|
|
387
|
+
user,
|
|
388
|
+
self.required_create_permission,
|
|
389
|
+
("all", "models:write", f"{self.slug}:create"),
|
|
390
|
+
action_enabled=self.can_create,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
def has_edit_permission(self, request: Request, user: AdminUser) -> bool:
|
|
394
|
+
"""Return whether `user` is permitted to edit records for this model."""
|
|
395
|
+
return self._has_action_permission(
|
|
396
|
+
user,
|
|
397
|
+
self.required_edit_permission,
|
|
398
|
+
("all", "models:write", f"{self.slug}:edit"),
|
|
399
|
+
action_enabled=self.can_edit,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
def has_delete_permission(self, request: Request, user: AdminUser) -> bool:
|
|
403
|
+
"""Return whether `user` is permitted to delete records for this model."""
|
|
404
|
+
return self._has_action_permission(
|
|
405
|
+
user,
|
|
406
|
+
self.required_delete_permission,
|
|
407
|
+
("all", "models:write", "models:delete", f"{self.slug}:delete"),
|
|
408
|
+
action_enabled=self.can_delete,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
def get_queryset(self) -> ModelQueryBuilder[Any]:
|
|
412
|
+
"""
|
|
413
|
+
Produce a base ModelQueryBuilder for list views.
|
|
414
|
+
Automatically eager-loads BelongsTo relationships to eliminate N+1 queries.
|
|
415
|
+
"""
|
|
416
|
+
builder = self.model.query()
|
|
417
|
+
relationships = self.get_relationships()
|
|
418
|
+
eager_rels = [
|
|
419
|
+
name
|
|
420
|
+
for name, meta in relationships.items()
|
|
421
|
+
if meta.relation_type == "BelongsTo"
|
|
422
|
+
]
|
|
423
|
+
if eager_rels:
|
|
424
|
+
builder.with_(*eager_rels)
|
|
425
|
+
if self.ordering:
|
|
426
|
+
raw_order = self.ordering.strip()
|
|
427
|
+
if raw_order.startswith("-") and len(raw_order) > 1:
|
|
428
|
+
builder.order_by(raw_order[1:].strip(), "DESC")
|
|
429
|
+
else:
|
|
430
|
+
parts = raw_order.split()
|
|
431
|
+
if len(parts) == 2 and parts[1].upper() in ("ASC", "DESC"):
|
|
432
|
+
builder.order_by(parts[0], parts[1].upper())
|
|
433
|
+
else:
|
|
434
|
+
builder.order_by(raw_order, "ASC")
|
|
435
|
+
return builder
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
_ROUTE_COMPILER: Callable[[AdminSite, Application, str], None] | None = None
|
|
439
|
+
_ROUTER_FACTORY: Callable[[AdminSite, str], Router] | None = None
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def set_admin_route_compiler(
|
|
443
|
+
compiler: Callable[[AdminSite, Application, str], None],
|
|
444
|
+
factory: Callable[[AdminSite, str], Router],
|
|
445
|
+
) -> None:
|
|
446
|
+
"""Register views compiler callbacks to avoid circular or inline imports."""
|
|
447
|
+
global _ROUTE_COMPILER, _ROUTER_FACTORY
|
|
448
|
+
_ROUTE_COMPILER = compiler
|
|
449
|
+
_ROUTER_FACTORY = factory
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
class AdminSite:
|
|
453
|
+
"""
|
|
454
|
+
Central administrative registry managing registered Models and generating CRUD screens.
|
|
455
|
+
"""
|
|
456
|
+
|
|
457
|
+
def __init__(self, title: str = "SwiftPY Admin") -> None:
|
|
458
|
+
self.title = title
|
|
459
|
+
self._registry: dict[type[Model], ModelAdmin] = {}
|
|
460
|
+
self._slug_registry: dict[str, tuple[type[Model], ModelAdmin]] = {}
|
|
461
|
+
|
|
462
|
+
def register(
|
|
463
|
+
self,
|
|
464
|
+
model_or_admin: type[Model | ModelAdmin] | None = None,
|
|
465
|
+
admin_class: type[ModelAdmin] | None = None,
|
|
466
|
+
) -> Any:
|
|
467
|
+
"""
|
|
468
|
+
Register a Model with the AdminSite.
|
|
469
|
+
Supports:
|
|
470
|
+
1. Direct invocation: admin.register(Model, ModelAdmin)
|
|
471
|
+
2. Direct invocation: admin.register(Model)
|
|
472
|
+
3. Decorator on Model: @admin.register class MyModel(Model): ...
|
|
473
|
+
4. Decorator on ModelAdmin: @admin.register(MyModel) class MyAdmin(ModelAdmin): ...
|
|
474
|
+
"""
|
|
475
|
+
# Check non-Model parameter
|
|
476
|
+
if model_or_admin is not None and not (isinstance(model_or_admin, type) and issubclass(model_or_admin, Model)):
|
|
477
|
+
raise TypeError(f"AdminSite.register() expects a Model subclass, got {model_or_admin!r}.")
|
|
478
|
+
|
|
479
|
+
# Case 1: admin.register(MyModel, MyAdmin)
|
|
480
|
+
if (
|
|
481
|
+
isinstance(model_or_admin, type)
|
|
482
|
+
and issubclass(model_or_admin, Model)
|
|
483
|
+
and admin_class is not None
|
|
484
|
+
):
|
|
485
|
+
instance = admin_class(model_or_admin, site=self)
|
|
486
|
+
self._register_model_admin(model_or_admin, instance)
|
|
487
|
+
return instance
|
|
488
|
+
|
|
489
|
+
# Case 2: @admin.register(MyModel) or admin.register(MyModel)
|
|
490
|
+
if (
|
|
491
|
+
isinstance(model_or_admin, type)
|
|
492
|
+
and issubclass(model_or_admin, Model)
|
|
493
|
+
and admin_class is None
|
|
494
|
+
):
|
|
495
|
+
instance = ModelAdmin(model_or_admin, site=self)
|
|
496
|
+
self._register_model_admin(model_or_admin, instance)
|
|
497
|
+
return instance
|
|
498
|
+
|
|
499
|
+
# Case 3: parameterless decorator: @admin.register class MyModel(Model): ...
|
|
500
|
+
def parameterless_decorator(cls: type[Model]) -> type[Model]:
|
|
501
|
+
if not isinstance(cls, type) or not issubclass(cls, Model):
|
|
502
|
+
raise TypeError(f"AdminSite.register() expects a Model subclass, got {cls!r}.")
|
|
503
|
+
instance = ModelAdmin(cls, site=self)
|
|
504
|
+
self._register_model_admin(cls, instance)
|
|
505
|
+
return cls
|
|
506
|
+
|
|
507
|
+
return parameterless_decorator
|
|
508
|
+
|
|
509
|
+
def _register_model_admin(self, model: type[Model], admin_instance: ModelAdmin) -> None:
|
|
510
|
+
if model in self._registry:
|
|
511
|
+
raise ValueError(f"Model '{model.__name__}' is already registered with AdminSite '{self.title}'.")
|
|
512
|
+
if admin_instance.slug in self._slug_registry:
|
|
513
|
+
existing_model = self._slug_registry[admin_instance.slug][0]
|
|
514
|
+
raise ValueError(
|
|
515
|
+
f"Admin slug collision: '{admin_instance.slug}' is already registered for model '{existing_model.__name__}'."
|
|
516
|
+
)
|
|
517
|
+
self._registry[model] = admin_instance
|
|
518
|
+
self._slug_registry[admin_instance.slug] = (model, admin_instance)
|
|
519
|
+
|
|
520
|
+
def unregister(self, model: type[Model]) -> None:
|
|
521
|
+
"""Remove a model from the admin registry."""
|
|
522
|
+
admin_instance = self._registry.pop(model, None)
|
|
523
|
+
if admin_instance:
|
|
524
|
+
self._slug_registry.pop(admin_instance.slug, None)
|
|
525
|
+
|
|
526
|
+
def is_registered(self, model: type[Model]) -> bool:
|
|
527
|
+
"""Check if a Model class is registered in the admin site."""
|
|
528
|
+
return model in self._registry
|
|
529
|
+
|
|
530
|
+
def get_model_admin(self, model: type[Model]) -> ModelAdmin:
|
|
531
|
+
"""Retrieve the ModelAdmin instance for a registered model."""
|
|
532
|
+
if model not in self._registry:
|
|
533
|
+
raise KeyError(f"Model '{model.__name__}' is not registered with the admin site.")
|
|
534
|
+
return self._registry[model]
|
|
535
|
+
|
|
536
|
+
def get_by_slug(self, slug: str) -> tuple[type[Model], ModelAdmin] | None:
|
|
537
|
+
"""Retrieve (Model, ModelAdmin) pair by its URL slug."""
|
|
538
|
+
return self._slug_registry.get(slug.lower().strip())
|
|
539
|
+
|
|
540
|
+
def all_registered(self) -> list[ModelAdmin]:
|
|
541
|
+
"""Return all registered ModelAdmin instances."""
|
|
542
|
+
return list(self._registry.values())
|
|
543
|
+
|
|
544
|
+
def mount(self, app: Application, prefix: str = "/admin") -> None:
|
|
545
|
+
"""Mount auto-generated CRUD routes onto a SwiftPY Application."""
|
|
546
|
+
if _ROUTE_COMPILER is None:
|
|
547
|
+
raise RuntimeError("Admin views route compiler is not initialized.")
|
|
548
|
+
_ROUTE_COMPILER(self, app, prefix)
|
|
549
|
+
|
|
550
|
+
def get_router(self, prefix: str = "/admin") -> Router:
|
|
551
|
+
"""Return a configured Router containing all admin CRUD routes."""
|
|
552
|
+
if _ROUTER_FACTORY is None:
|
|
553
|
+
raise RuntimeError("Admin views router factory is not initialized.")
|
|
554
|
+
return _ROUTER_FACTORY(self, prefix)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
# Global singleton instance
|
|
558
|
+
admin = AdminSite()
|
|
559
|
+
|
|
560
|
+
__all__ = [
|
|
561
|
+
"AdminSite",
|
|
562
|
+
"FieldMetadata",
|
|
563
|
+
"ModelAdmin",
|
|
564
|
+
"RelationshipMetadata",
|
|
565
|
+
"admin",
|
|
566
|
+
"humanize_name",
|
|
567
|
+
]
|