telamon 0.1.0a0__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.
- telamon/__init__.py +51 -0
- telamon/backends/__init__.py +0 -0
- telamon/backends/beanie/__init__.py +3 -0
- telamon/backends/beanie/backend.py +147 -0
- telamon/backends/memory/__init__.py +201 -0
- telamon/backends/mongo/__init__.py +4 -0
- telamon/backends/mongo/backend.py +301 -0
- telamon/backends/mongo/common.py +320 -0
- telamon/backends/sqlalchemy/__init__.py +3 -0
- telamon/backends/sqlalchemy/backend.py +497 -0
- telamon/backends/tortoise/__init__.py +3 -0
- telamon/backends/tortoise/backend.py +369 -0
- telamon/core/__init__.py +50 -0
- telamon/core/auth.py +183 -0
- telamon/core/coerce.py +157 -0
- telamon/core/exceptions.py +49 -0
- telamon/core/filters.py +100 -0
- telamon/core/http.py +117 -0
- telamon/core/model_admin.py +205 -0
- telamon/core/query.py +45 -0
- telamon/core/repository.py +49 -0
- telamon/core/schema.py +126 -0
- telamon/core/site.py +461 -0
- telamon/py.typed +0 -0
- telamon/static/assets/index-DRNd8E2f.css +2 -0
- telamon/static/assets/index-rsbgmkee.js +11 -0
- telamon/static/favicon.svg +1 -0
- telamon/static/icons.svg +24 -0
- telamon/static/index.html +15 -0
- telamon/transports/__init__.py +0 -0
- telamon/transports/starlette.py +125 -0
- telamon-0.1.0a0.dist-info/METADATA +169 -0
- telamon-0.1.0a0.dist-info/RECORD +35 -0
- telamon-0.1.0a0.dist-info/WHEEL +4 -0
- telamon-0.1.0a0.dist-info/licenses/LICENSE +21 -0
telamon/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Telamon Admin: framework-agnostic admin panel for SQLAlchemy and Tortoise ORM."""
|
|
2
|
+
|
|
3
|
+
from .core import (
|
|
4
|
+
Action,
|
|
5
|
+
AdminError,
|
|
6
|
+
AdminRequest,
|
|
7
|
+
AdminResponse,
|
|
8
|
+
AdminSite,
|
|
9
|
+
AuthProvider,
|
|
10
|
+
Backend,
|
|
11
|
+
FieldType,
|
|
12
|
+
FilterGroup,
|
|
13
|
+
ListQuery,
|
|
14
|
+
ModelAdmin,
|
|
15
|
+
ModelSchema,
|
|
16
|
+
NotFound,
|
|
17
|
+
PermissionDenied,
|
|
18
|
+
Repository,
|
|
19
|
+
SessionAuth,
|
|
20
|
+
SimpleAuth,
|
|
21
|
+
User,
|
|
22
|
+
ValidationError,
|
|
23
|
+
action,
|
|
24
|
+
hash_password,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0a0"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Action",
|
|
31
|
+
"AdminError",
|
|
32
|
+
"AdminRequest",
|
|
33
|
+
"AdminResponse",
|
|
34
|
+
"AdminSite",
|
|
35
|
+
"AuthProvider",
|
|
36
|
+
"Backend",
|
|
37
|
+
"FieldType",
|
|
38
|
+
"FilterGroup",
|
|
39
|
+
"ListQuery",
|
|
40
|
+
"ModelAdmin",
|
|
41
|
+
"ModelSchema",
|
|
42
|
+
"NotFound",
|
|
43
|
+
"PermissionDenied",
|
|
44
|
+
"Repository",
|
|
45
|
+
"SessionAuth",
|
|
46
|
+
"SimpleAuth",
|
|
47
|
+
"User",
|
|
48
|
+
"ValidationError",
|
|
49
|
+
"action",
|
|
50
|
+
"hash_password",
|
|
51
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Beanie ODM backend. ``Link[T]`` -> many_to_one, ``list[Link[T]]`` -> many_to_many,
|
|
2
|
+
``BackLink[T]`` -> one_to_many (read-only). Requires ``init_beanie()`` before the first request.
|
|
3
|
+
|
|
4
|
+
Documents are read and written through the raw pymongo collection that Beanie manages; links are
|
|
5
|
+
stored as DBRefs exactly as Beanie stores them, so both sides stay compatible.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import builtins
|
|
11
|
+
from typing import Any, cast, get_args, get_origin
|
|
12
|
+
|
|
13
|
+
from beanie import Document
|
|
14
|
+
from beanie.odm.fields import BackLink, Link
|
|
15
|
+
from bson import DBRef
|
|
16
|
+
|
|
17
|
+
from telamon.core.model_admin import ModelAdmin
|
|
18
|
+
from telamon.core.schema import FieldSchema, FieldType, ModelSchema, Relation
|
|
19
|
+
|
|
20
|
+
from ..mongo.backend import Driver, MongoBackend, MongoRepository
|
|
21
|
+
from ..mongo.common import Ref, _unwrap, field_from_annotation, parse_pk
|
|
22
|
+
|
|
23
|
+
_HIDDEN = {"revision_id"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BeanieBackend(MongoBackend):
|
|
27
|
+
name = "beanie"
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None: # database comes from init_beanie()
|
|
30
|
+
self._driver: Driver | None = None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def driver(self) -> Driver:
|
|
34
|
+
if self._driver is None:
|
|
35
|
+
raise RuntimeError(
|
|
36
|
+
"Beanie is not initialised; call init_beanie() before using the admin"
|
|
37
|
+
)
|
|
38
|
+
return self._driver
|
|
39
|
+
|
|
40
|
+
@driver.setter
|
|
41
|
+
def driver(self, value: Driver) -> None:
|
|
42
|
+
self._driver = value
|
|
43
|
+
|
|
44
|
+
def supports(self, model: type) -> bool:
|
|
45
|
+
return isinstance(model, type) and issubclass(model, Document)
|
|
46
|
+
|
|
47
|
+
def introspect(self, model: type, admin: ModelAdmin) -> ModelSchema:
|
|
48
|
+
fields: list[FieldSchema] = []
|
|
49
|
+
doc = cast(type[Document], model)
|
|
50
|
+
for name, info in doc.model_fields.items():
|
|
51
|
+
if name in _HIDDEN:
|
|
52
|
+
continue
|
|
53
|
+
if name == "id":
|
|
54
|
+
f = field_from_annotation("id", info.annotation, info)
|
|
55
|
+
f.primary_key, f.read_only, f.has_default, f.required, f.nullable = (
|
|
56
|
+
True,
|
|
57
|
+
True,
|
|
58
|
+
True,
|
|
59
|
+
False,
|
|
60
|
+
False,
|
|
61
|
+
)
|
|
62
|
+
fields.insert(0, f)
|
|
63
|
+
continue
|
|
64
|
+
core, nullable, _ = _unwrap(info.annotation)
|
|
65
|
+
origin = get_origin(core)
|
|
66
|
+
inner = get_args(core)[0] if origin in (list, set, tuple) and get_args(core) else None
|
|
67
|
+
if origin is Link:
|
|
68
|
+
target = get_args(core)[0]
|
|
69
|
+
ann: Any = (str | None) if nullable else str
|
|
70
|
+
fields.append(field_from_annotation(name, ann, info, extra_metadata=[Ref(target)]))
|
|
71
|
+
elif inner is not None and get_origin(inner) is Link:
|
|
72
|
+
target = get_args(inner)[0]
|
|
73
|
+
fields.append(
|
|
74
|
+
field_from_annotation(name, list[str], info, extra_metadata=[Ref(target)])
|
|
75
|
+
)
|
|
76
|
+
elif origin is BackLink or (inner is not None and get_origin(inner) is BackLink):
|
|
77
|
+
link = core if origin is BackLink else inner
|
|
78
|
+
target = get_args(link)[0] if get_args(link) else Any
|
|
79
|
+
fields.append(
|
|
80
|
+
FieldSchema(
|
|
81
|
+
name,
|
|
82
|
+
FieldType.ONE_TO_MANY,
|
|
83
|
+
read_only=True,
|
|
84
|
+
relation=Relation(model=target, kind=FieldType.ONE_TO_MANY),
|
|
85
|
+
sortable=False,
|
|
86
|
+
filterable=False,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
else:
|
|
90
|
+
fields.append(field_from_annotation(name, info.annotation, info))
|
|
91
|
+
label = model.__name__
|
|
92
|
+
return ModelSchema(
|
|
93
|
+
key=label.lower(), label=label, label_plural=f"{label}s", pk_field="id", fields=fields
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def repository(self, model: type, schema: ModelSchema, admin: ModelAdmin) -> BeanieRepository:
|
|
97
|
+
return BeanieRepository(self, model, schema, admin)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class BeanieRepository(MongoRepository):
|
|
101
|
+
id_path = ".$id"
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def driver(self) -> Driver:
|
|
105
|
+
_ = self.coll # resolving the collection also wires the driver from init_beanie()
|
|
106
|
+
return self.backend.driver
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def coll(self) -> Any:
|
|
110
|
+
if self._coll is None:
|
|
111
|
+
doc = cast(type[Document], self.model)
|
|
112
|
+
self._coll = doc.get_pymongo_collection() # raises if init_beanie() was not run
|
|
113
|
+
self.backend.driver = Driver(self._coll.database)
|
|
114
|
+
return self._coll
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def collection_name(self) -> str:
|
|
118
|
+
return str(cast(type[Document], self.model).get_collection_name())
|
|
119
|
+
|
|
120
|
+
def _target_collection(self, f: FieldSchema) -> str:
|
|
121
|
+
assert f.relation is not None
|
|
122
|
+
return str(f.relation.model.get_collection_name())
|
|
123
|
+
|
|
124
|
+
def _rel_value(self, f: FieldSchema, value: Any) -> Any:
|
|
125
|
+
if value is None:
|
|
126
|
+
return None
|
|
127
|
+
coll = self._target_collection(f)
|
|
128
|
+
if f.type == FieldType.MANY_TO_MANY:
|
|
129
|
+
return [DBRef(coll, parse_pk(v)) for v in value]
|
|
130
|
+
return DBRef(coll, parse_pk(value))
|
|
131
|
+
|
|
132
|
+
def _rel_ids(self, f: FieldSchema, stored: Any) -> builtins.list[Any]:
|
|
133
|
+
if stored is None:
|
|
134
|
+
return []
|
|
135
|
+
items = stored if isinstance(stored, list) else [stored]
|
|
136
|
+
out = []
|
|
137
|
+
for i in items:
|
|
138
|
+
if isinstance(i, DBRef):
|
|
139
|
+
out.append(i.id)
|
|
140
|
+
elif isinstance(i, dict) and "$id" in i:
|
|
141
|
+
out.append(i["$id"])
|
|
142
|
+
else:
|
|
143
|
+
out.append(i)
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
__all__ = ["BeanieBackend", "BeanieRepository"]
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""In-memory backend: reference implementation of the Repository contract, used by core tests.
|
|
2
|
+
|
|
3
|
+
Models are plain classes; their schema is passed explicitly. Rows are dicts keyed by pk.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from telamon.core.exceptions import ValidationError
|
|
12
|
+
from telamon.core.filters import Condition, FilterGroup, Op
|
|
13
|
+
from telamon.core.model_admin import ModelAdmin
|
|
14
|
+
from telamon.core.query import ListQuery, Page
|
|
15
|
+
from telamon.core.schema import FieldType, ModelSchema
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def matches(cond: Condition, value: Any) -> bool: # noqa: C901
|
|
19
|
+
return bool(_matches(cond, value))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _matches(cond: Condition, value: Any) -> Any:
|
|
23
|
+
op, target = cond.op, cond.value
|
|
24
|
+
if op == Op.IS_NULL:
|
|
25
|
+
return (value is None) == bool(target)
|
|
26
|
+
if op == Op.EQ:
|
|
27
|
+
return value == target
|
|
28
|
+
if op == Op.NE:
|
|
29
|
+
return value != target
|
|
30
|
+
if value is None:
|
|
31
|
+
return False
|
|
32
|
+
if op == Op.LT:
|
|
33
|
+
return value < target
|
|
34
|
+
if op == Op.LTE:
|
|
35
|
+
return value <= target
|
|
36
|
+
if op == Op.GT:
|
|
37
|
+
return value > target
|
|
38
|
+
if op == Op.GTE:
|
|
39
|
+
return value >= target
|
|
40
|
+
if op == Op.IN:
|
|
41
|
+
if isinstance(value, list): # many_to_many: any overlap
|
|
42
|
+
return any(v in (target or []) for v in value)
|
|
43
|
+
return value in (target or [])
|
|
44
|
+
if op == Op.NOT_IN:
|
|
45
|
+
if isinstance(value, list):
|
|
46
|
+
return not any(v in (target or []) for v in value)
|
|
47
|
+
return value not in (target or [])
|
|
48
|
+
if op == Op.CONTAINS:
|
|
49
|
+
return str(target) in str(value)
|
|
50
|
+
if op == Op.ICONTAINS:
|
|
51
|
+
return str(target).lower() in str(value).lower()
|
|
52
|
+
if op == Op.STARTSWITH:
|
|
53
|
+
return str(value).startswith(str(target))
|
|
54
|
+
if op == Op.ENDSWITH:
|
|
55
|
+
return str(value).endswith(str(target))
|
|
56
|
+
if op == Op.BETWEEN:
|
|
57
|
+
lo, hi = target
|
|
58
|
+
return lo <= value <= hi
|
|
59
|
+
raise ValueError(op)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def matches_group(group: FilterGroup, row: dict[str, Any]) -> bool:
|
|
63
|
+
if group.is_empty():
|
|
64
|
+
return True
|
|
65
|
+
results = (
|
|
66
|
+
matches(i, row.get(i.field)) if isinstance(i, Condition) else matches_group(i, row)
|
|
67
|
+
for i in group.items
|
|
68
|
+
)
|
|
69
|
+
return all(results) if group.logic == "and" else any(results)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class MemoryRepository:
|
|
73
|
+
def __init__(
|
|
74
|
+
self, backend: MemoryBackend, model: type, schema: ModelSchema, admin: ModelAdmin
|
|
75
|
+
) -> None:
|
|
76
|
+
self.backend = backend
|
|
77
|
+
self.model = model
|
|
78
|
+
self.schema = schema
|
|
79
|
+
self.admin = admin
|
|
80
|
+
self.rows: dict[Any, dict[str, Any]] = backend.storage.setdefault(model, {})
|
|
81
|
+
|
|
82
|
+
def _next_pk(self) -> int:
|
|
83
|
+
existing = [p for p in self.rows if isinstance(p, int)]
|
|
84
|
+
return (max(existing) + 1) if existing else 1
|
|
85
|
+
|
|
86
|
+
def _apply(self, query: ListQuery) -> list[dict[str, Any]]:
|
|
87
|
+
rows = [r for r in self.rows.values() if matches_group(query.filters, r)]
|
|
88
|
+
if query.search:
|
|
89
|
+
term = query.search.lower()
|
|
90
|
+
searchable = [f.name for f in self.schema.fields if f.searchable]
|
|
91
|
+
rows = [r for r in rows if any(term in str(r.get(n) or "").lower() for n in searchable)]
|
|
92
|
+
for name, direction in reversed(query.sort):
|
|
93
|
+
rows.sort(key=lambda r: (r.get(name) is None, r.get(name)), reverse=direction == "desc")
|
|
94
|
+
return rows
|
|
95
|
+
|
|
96
|
+
async def list(self, query: ListQuery) -> Page[dict[str, Any]]:
|
|
97
|
+
rows = self._apply(query)
|
|
98
|
+
return Page(items=rows[query.offset : query.offset + query.limit], total=len(rows))
|
|
99
|
+
|
|
100
|
+
async def count(self, query: ListQuery) -> int:
|
|
101
|
+
return len(self._apply(query))
|
|
102
|
+
|
|
103
|
+
async def get(self, pk: Any) -> dict[str, Any] | None:
|
|
104
|
+
return self.rows.get(pk)
|
|
105
|
+
|
|
106
|
+
async def get_many(self, pks: Sequence[Any]) -> Sequence[dict[str, Any]]:
|
|
107
|
+
return [self.rows[p] for p in pks if p in self.rows]
|
|
108
|
+
|
|
109
|
+
def _check_relations(self, data: dict[str, Any]) -> None:
|
|
110
|
+
for name, value in data.items():
|
|
111
|
+
f = self.schema.field(name)
|
|
112
|
+
if not f.is_relation or f.relation is None or value is None:
|
|
113
|
+
continue
|
|
114
|
+
rows = self.backend.storage.get(f.relation.model, {})
|
|
115
|
+
ids = value if isinstance(value, list) else [value]
|
|
116
|
+
if any(i not in rows for i in ids):
|
|
117
|
+
raise ValidationError({name: "related object does not exist"})
|
|
118
|
+
|
|
119
|
+
async def create(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
120
|
+
self._check_relations(data)
|
|
121
|
+
pk_name = self.schema.pk_field
|
|
122
|
+
row: dict[str, Any] = {
|
|
123
|
+
f.name: None for f in self.schema.fields if f.type != FieldType.ONE_TO_MANY
|
|
124
|
+
}
|
|
125
|
+
row.update(data)
|
|
126
|
+
if row.get(pk_name) is None:
|
|
127
|
+
row[pk_name] = self._next_pk()
|
|
128
|
+
if row[pk_name] in self.rows:
|
|
129
|
+
raise ValidationError({pk_name: "already exists"})
|
|
130
|
+
self.rows[row[pk_name]] = row
|
|
131
|
+
return row
|
|
132
|
+
|
|
133
|
+
async def update(self, obj: dict[str, Any], data: dict[str, Any]) -> dict[str, Any]:
|
|
134
|
+
self._check_relations(data)
|
|
135
|
+
obj.update(data)
|
|
136
|
+
return obj
|
|
137
|
+
|
|
138
|
+
async def delete(self, objs: Sequence[dict[str, Any]]) -> int:
|
|
139
|
+
n = 0
|
|
140
|
+
for o in objs:
|
|
141
|
+
if self.rows.pop(o[self.schema.pk_field], None) is not None:
|
|
142
|
+
n += 1
|
|
143
|
+
return n
|
|
144
|
+
|
|
145
|
+
def pk_of(self, obj: dict[str, Any]) -> Any:
|
|
146
|
+
return obj[self.schema.pk_field]
|
|
147
|
+
|
|
148
|
+
def label_of(self, obj: dict[str, Any]) -> str:
|
|
149
|
+
for f in self.schema.fields:
|
|
150
|
+
if f.type == FieldType.STRING and obj.get(f.name):
|
|
151
|
+
return str(obj[f.name])
|
|
152
|
+
return f"{self.schema.label} #{self.pk_of(obj)}"
|
|
153
|
+
|
|
154
|
+
def _related(self, f: Any, pk: Any) -> dict[str, Any]:
|
|
155
|
+
rows = self.backend.storage.get(f.relation.model, {})
|
|
156
|
+
target_schema = self.backend.schemas.get(f.relation.model)
|
|
157
|
+
row = rows.get(pk)
|
|
158
|
+
if row is None or target_schema is None:
|
|
159
|
+
return {"id": pk, "label": str(pk)}
|
|
160
|
+
repo = MemoryRepository(self.backend, f.relation.model, target_schema, self.admin)
|
|
161
|
+
return {"id": pk, "label": repo.label_of(row)}
|
|
162
|
+
|
|
163
|
+
def to_dict(self, obj: dict[str, Any]) -> dict[str, Any]:
|
|
164
|
+
out: dict[str, Any] = {}
|
|
165
|
+
for f in self.schema.fields:
|
|
166
|
+
if f.type == FieldType.ONE_TO_MANY:
|
|
167
|
+
continue
|
|
168
|
+
v = obj.get(f.name)
|
|
169
|
+
if f.type == FieldType.MANY_TO_ONE:
|
|
170
|
+
out[f.name] = None if v is None else self._related(f, v)
|
|
171
|
+
elif f.type == FieldType.MANY_TO_MANY:
|
|
172
|
+
out[f.name] = [self._related(f, i) for i in (v or [])]
|
|
173
|
+
else:
|
|
174
|
+
out[f.name] = v
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class MemoryBackend:
|
|
179
|
+
name = "memory"
|
|
180
|
+
|
|
181
|
+
def __init__(self, schemas: dict[type, ModelSchema] | None = None) -> None:
|
|
182
|
+
self.schemas: dict[type, ModelSchema] = dict(schemas or {})
|
|
183
|
+
self.storage: dict[type, dict[Any, dict[str, Any]]] = {}
|
|
184
|
+
|
|
185
|
+
def add(self, model: type, schema: ModelSchema) -> None:
|
|
186
|
+
self.schemas[model] = schema
|
|
187
|
+
|
|
188
|
+
def supports(self, model: type) -> bool:
|
|
189
|
+
return model in self.schemas
|
|
190
|
+
|
|
191
|
+
def introspect(self, model: type, admin: ModelAdmin) -> ModelSchema:
|
|
192
|
+
import copy
|
|
193
|
+
|
|
194
|
+
return copy.deepcopy(self.schemas[model])
|
|
195
|
+
|
|
196
|
+
def repository(self, model: type, schema: ModelSchema, admin: ModelAdmin) -> MemoryRepository:
|
|
197
|
+
return MemoryRepository(self, model, schema, admin)
|
|
198
|
+
|
|
199
|
+
def seed(self, model: type, rows: list[dict[str, Any]]) -> None:
|
|
200
|
+
pk = self.schemas[model].pk_field
|
|
201
|
+
self.storage.setdefault(model, {}).update({r[pk]: dict(r) for r in rows})
|