@palettelab/cli 0.3.23 → 0.3.25
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.
- package/README.md +127 -0
- package/backend-sdk/palette_sdk/__init__.py +7 -1
- package/backend-sdk/palette_sdk/data_rooms.py +110 -0
- package/backend-sdk/palette_sdk/lifecycle.py +58 -0
- package/backend-sdk/palette_sdk/plugin_context.py +30 -0
- package/backend-sdk/palette_sdk/repository.py +119 -0
- package/backend-sdk/pyproject.toml +1 -1
- package/lib/dev-simulator.js +2 -0
- package/package.json +1 -1
- package/template-fallback/package.json +1 -1
- package/template-fallback/templates/dashboard/package.json +1 -1
- package/template-fallback/templates/database/backend/api/main.py +2 -11
- package/template-fallback/templates/database/package.json +1 -1
- package/template-fallback/templates/external-service/package.json +1 -1
- package/template-fallback/templates/frontend-only/package.json +1 -1
package/README.md
CHANGED
|
@@ -193,6 +193,133 @@ Use the right command for the kind of test you need:
|
|
|
193
193
|
For normal app developers, Docker is not required. Docker is only needed for
|
|
194
194
|
internal platform parity checks with `pltt dev --platform`.
|
|
195
195
|
|
|
196
|
+
## App-Owned Data, Migrations, And Python Backends
|
|
197
|
+
|
|
198
|
+
Palette apps can own their own Python backend, database tables, migrations, and
|
|
199
|
+
organization-scoped data. The generated database template uses this structure:
|
|
200
|
+
|
|
201
|
+
```text
|
|
202
|
+
my-app/
|
|
203
|
+
palette-plugin.json
|
|
204
|
+
frontend/src/index.tsx
|
|
205
|
+
backend/api/main.py
|
|
206
|
+
backend/api/models.py
|
|
207
|
+
backend/migrations/env.py
|
|
208
|
+
backend/migrations/versions/001_init.py
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Declare database ownership in `palette-plugin.json`:
|
|
212
|
+
|
|
213
|
+
```json
|
|
214
|
+
{
|
|
215
|
+
"capabilities": { "database": true },
|
|
216
|
+
"database": {
|
|
217
|
+
"schema": "app_my_app",
|
|
218
|
+
"migrations": "./backend/migrations"
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Define org-scoped models with the backend SDK:
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
from sqlalchemy import String
|
|
227
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
228
|
+
from palette_sdk import OrgScopedTable
|
|
229
|
+
|
|
230
|
+
class Invoice(OrgScopedTable):
|
|
231
|
+
__tablename__ = "invoices"
|
|
232
|
+
|
|
233
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
234
|
+
customer_name: Mapped[str] = mapped_column(String(255))
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Use the migration helper in Alembic migrations:
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
from palette_sdk.db import ensure_org_rls
|
|
241
|
+
|
|
242
|
+
def upgrade():
|
|
243
|
+
op.create_table(...)
|
|
244
|
+
ensure_org_rls(op, "invoices")
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Use `ctx.repo(Model)` for org-safe CRUD:
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
from fastapi import Depends
|
|
251
|
+
from palette_sdk import PluginContext, get_plugin_context
|
|
252
|
+
from models import Invoice
|
|
253
|
+
|
|
254
|
+
@router.get("/invoices")
|
|
255
|
+
async def list_invoices(ctx: PluginContext = Depends(get_plugin_context)):
|
|
256
|
+
rows = await ctx.repo(Invoice).list(order_by="-id")
|
|
257
|
+
return [{"id": r.id, "customer": r.customer_name} for r in rows]
|
|
258
|
+
|
|
259
|
+
@router.post("/invoices")
|
|
260
|
+
async def create_invoice(body: InvoiceIn, ctx: PluginContext = Depends(get_plugin_context)):
|
|
261
|
+
invoice = await ctx.repo(Invoice).create(**body.model_dump())
|
|
262
|
+
return {"id": invoice.id}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Backend SDK features for app-owned data:
|
|
266
|
+
|
|
267
|
+
- `PluginContext` exposes `user_id`, `organization_id`, `plugin_id`, `permissions`, `config`, `storage`, and `ctx.db`.
|
|
268
|
+
- `ctx.repo(Model)` gives org-safe CRUD helpers for app tables.
|
|
269
|
+
- `ctx.data_rooms` gives backend access to Palette Data Rooms without importing platform internals.
|
|
270
|
+
- `ctx.has_permission("...")` checks declared permissions.
|
|
271
|
+
- `ctx.config_value("key")` and `ctx.require_config("key")` read app install/config values.
|
|
272
|
+
- `ctx.secret("KEY")` reads app secrets from config or environment variables.
|
|
273
|
+
- `LifecycleHooks` lets apps define install/update/enable/disable/uninstall hooks.
|
|
274
|
+
- `OrgScopedTable` and `PluginBase` keep app data inside the plugin schema model set.
|
|
275
|
+
|
|
276
|
+
Python backend Data Room example:
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
@router.post("/sync-invoices", dependencies=[require_permission("data_rooms:write")])
|
|
280
|
+
async def sync_invoices(ctx: PluginContext = Depends(get_plugin_context)):
|
|
281
|
+
room = await ctx.data_rooms.ensure_room("Finance")
|
|
282
|
+
folder = await ctx.data_rooms.resolve_folder_path(
|
|
283
|
+
room["id"],
|
|
284
|
+
"Clients/Acme/Invoices",
|
|
285
|
+
create=True,
|
|
286
|
+
)
|
|
287
|
+
file = await ctx.data_rooms.find_file_by_name(
|
|
288
|
+
room["id"],
|
|
289
|
+
"jan.pdf",
|
|
290
|
+
folder_id=folder["id"] if folder else None,
|
|
291
|
+
)
|
|
292
|
+
content = await ctx.data_rooms.read_file_bytes(file["id"]) if file else None
|
|
293
|
+
return {"room": room, "folder": folder, "bytes": len(content or b"")}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
The npm `@palettelab/sdk` package is for frontend JavaScript/React apps.
|
|
297
|
+
Python backend code uses `palette_sdk`, which is embedded in the CLI for
|
|
298
|
+
local dev/tests and injected by the hosted Palette runtime.
|
|
299
|
+
|
|
300
|
+
Lifecycle example:
|
|
301
|
+
|
|
302
|
+
```python
|
|
303
|
+
from palette_sdk import LifecycleHooks, PluginContext
|
|
304
|
+
|
|
305
|
+
lifecycle = LifecycleHooks()
|
|
306
|
+
|
|
307
|
+
@lifecycle.on_install
|
|
308
|
+
async def seed_defaults(ctx: PluginContext):
|
|
309
|
+
await ctx.repo(DefaultSetting).create(name="currency", value="USD")
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Run local checks before publishing:
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
npx --yes @palettelab/cli@latest test
|
|
316
|
+
npx --yes @palettelab/cli@latest package
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
The CLI validates manifest shape, SDK compatibility, frontend bundling, backend
|
|
320
|
+
imports, backend route permission gates, declared permissions, migration safety,
|
|
321
|
+
package dependency policy, and backend package size.
|
|
322
|
+
|
|
196
323
|
## Commands
|
|
197
324
|
|
|
198
325
|
### `pltt init <name>`
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from palette_sdk.plugin_router import PluginRouter
|
|
4
4
|
from palette_sdk.plugin_context import PluginContext, get_plugin_context
|
|
5
|
+
from palette_sdk.data_rooms import DataRoomsClient
|
|
6
|
+
from palette_sdk.repository import OrgRepository
|
|
7
|
+
from palette_sdk.lifecycle import LifecycleHooks
|
|
5
8
|
from palette_sdk.tool_definition import ToolDefinition
|
|
6
9
|
from palette_sdk.manifest import PluginManifest, load_manifest
|
|
7
10
|
from palette_sdk.schemas import (
|
|
@@ -24,6 +27,9 @@ __all__ = [
|
|
|
24
27
|
"PluginRouter",
|
|
25
28
|
"PluginContext",
|
|
26
29
|
"get_plugin_context",
|
|
30
|
+
"DataRoomsClient",
|
|
31
|
+
"OrgRepository",
|
|
32
|
+
"LifecycleHooks",
|
|
27
33
|
"ToolDefinition",
|
|
28
34
|
"PluginManifest",
|
|
29
35
|
"load_manifest",
|
|
@@ -45,4 +51,4 @@ __all__ = [
|
|
|
45
51
|
"route_permission_issues",
|
|
46
52
|
]
|
|
47
53
|
|
|
48
|
-
__version__ = "0.1.
|
|
54
|
+
__version__ = "0.1.5"
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Backend Data Room helpers for plugin Python code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DataRoomsClient:
|
|
9
|
+
"""Thin wrapper around the platform-injected Data Room service.
|
|
10
|
+
|
|
11
|
+
In production/hosted sandbox the platform injects the service into
|
|
12
|
+
`PluginContext`. In local unit tests, pass a fake service with the same
|
|
13
|
+
async methods.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, service: Any = None):
|
|
17
|
+
self._service = service
|
|
18
|
+
|
|
19
|
+
def _require_service(self) -> Any:
|
|
20
|
+
if self._service is None:
|
|
21
|
+
raise RuntimeError(
|
|
22
|
+
"Data Room service is not available in this runtime. "
|
|
23
|
+
"Run inside Palette OS/hosted sandbox or inject a fake service in tests."
|
|
24
|
+
)
|
|
25
|
+
return self._service
|
|
26
|
+
|
|
27
|
+
async def list(self) -> list[dict[str, Any]]:
|
|
28
|
+
return await self._require_service().list_rooms()
|
|
29
|
+
|
|
30
|
+
async def create(self, name: str, description: str | None = None) -> dict[str, Any]:
|
|
31
|
+
return await self._require_service().create_room(name, description)
|
|
32
|
+
|
|
33
|
+
async def find_room_by_name(self, name: str, *, case_sensitive: bool = False) -> dict[str, Any] | None:
|
|
34
|
+
return await self._require_service().find_room_by_name(name, case_sensitive=case_sensitive)
|
|
35
|
+
|
|
36
|
+
async def require_room_by_name(self, name: str, *, case_sensitive: bool = False) -> dict[str, Any]:
|
|
37
|
+
room = await self.find_room_by_name(name, case_sensitive=case_sensitive)
|
|
38
|
+
if room is None:
|
|
39
|
+
raise LookupError(f"Data Room not found: {name}")
|
|
40
|
+
return room
|
|
41
|
+
|
|
42
|
+
async def ensure_room(self, name: str, description: str | None = None) -> dict[str, Any]:
|
|
43
|
+
return await self._require_service().ensure_room(name, description)
|
|
44
|
+
|
|
45
|
+
async def contents(self, room_id: int, folder_id: int | None = None) -> dict[str, Any]:
|
|
46
|
+
return await self._require_service().contents(room_id, folder_id)
|
|
47
|
+
|
|
48
|
+
async def create_folder(
|
|
49
|
+
self,
|
|
50
|
+
room_id: int,
|
|
51
|
+
name: str,
|
|
52
|
+
parent_folder_id: int | None = None,
|
|
53
|
+
) -> dict[str, Any]:
|
|
54
|
+
return await self._require_service().create_folder(room_id, name, parent_folder_id)
|
|
55
|
+
|
|
56
|
+
async def find_folder_by_name(
|
|
57
|
+
self,
|
|
58
|
+
room_id: int,
|
|
59
|
+
name: str,
|
|
60
|
+
*,
|
|
61
|
+
parent_folder_id: int | None = None,
|
|
62
|
+
case_sensitive: bool = False,
|
|
63
|
+
) -> dict[str, Any] | None:
|
|
64
|
+
return await self._require_service().find_folder_by_name(
|
|
65
|
+
room_id,
|
|
66
|
+
name,
|
|
67
|
+
parent_folder_id=parent_folder_id,
|
|
68
|
+
case_sensitive=case_sensitive,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
async def ensure_folder(
|
|
72
|
+
self,
|
|
73
|
+
room_id: int,
|
|
74
|
+
name: str,
|
|
75
|
+
parent_folder_id: int | None = None,
|
|
76
|
+
) -> dict[str, Any]:
|
|
77
|
+
return await self._require_service().ensure_folder(room_id, name, parent_folder_id)
|
|
78
|
+
|
|
79
|
+
async def resolve_folder_path(
|
|
80
|
+
self,
|
|
81
|
+
room_id: int,
|
|
82
|
+
path: str | list[str],
|
|
83
|
+
*,
|
|
84
|
+
create: bool = False,
|
|
85
|
+
case_sensitive: bool = False,
|
|
86
|
+
) -> dict[str, Any] | None:
|
|
87
|
+
return await self._require_service().resolve_folder_path(
|
|
88
|
+
room_id,
|
|
89
|
+
path,
|
|
90
|
+
create=create,
|
|
91
|
+
case_sensitive=case_sensitive,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
async def find_file_by_name(
|
|
95
|
+
self,
|
|
96
|
+
room_id: int,
|
|
97
|
+
name: str,
|
|
98
|
+
*,
|
|
99
|
+
folder_id: int | None = None,
|
|
100
|
+
case_sensitive: bool = False,
|
|
101
|
+
) -> dict[str, Any] | None:
|
|
102
|
+
return await self._require_service().find_file_by_name(
|
|
103
|
+
room_id,
|
|
104
|
+
name,
|
|
105
|
+
folder_id=folder_id,
|
|
106
|
+
case_sensitive=case_sensitive,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
async def read_file_bytes(self, file_id: int) -> bytes:
|
|
110
|
+
return await self._require_service().read_file_bytes(file_id)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Lifecycle hook registry for install/update/uninstall behavior."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from palette_sdk.plugin_context import PluginContext
|
|
10
|
+
|
|
11
|
+
LifecycleEvent = Literal["install", "update", "enable", "disable", "uninstall"]
|
|
12
|
+
LifecycleHandler = Callable[[PluginContext], Awaitable[None]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class LifecycleHooks:
|
|
17
|
+
"""Register optional app lifecycle hooks.
|
|
18
|
+
|
|
19
|
+
App backends can export a `lifecycle` object from `backend/api/main.py`.
|
|
20
|
+
The platform can call these hooks during install/update/uninstall, and tests
|
|
21
|
+
can call `run()` directly.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
_handlers: dict[LifecycleEvent, list[LifecycleHandler]] = field(
|
|
25
|
+
default_factory=lambda: {
|
|
26
|
+
"install": [],
|
|
27
|
+
"update": [],
|
|
28
|
+
"enable": [],
|
|
29
|
+
"disable": [],
|
|
30
|
+
"uninstall": [],
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def on(self, event: LifecycleEvent):
|
|
35
|
+
def decorator(fn: LifecycleHandler) -> LifecycleHandler:
|
|
36
|
+
self._handlers[event].append(fn)
|
|
37
|
+
return fn
|
|
38
|
+
|
|
39
|
+
return decorator
|
|
40
|
+
|
|
41
|
+
def on_install(self, fn: LifecycleHandler) -> LifecycleHandler:
|
|
42
|
+
return self.on("install")(fn)
|
|
43
|
+
|
|
44
|
+
def on_update(self, fn: LifecycleHandler) -> LifecycleHandler:
|
|
45
|
+
return self.on("update")(fn)
|
|
46
|
+
|
|
47
|
+
def on_enable(self, fn: LifecycleHandler) -> LifecycleHandler:
|
|
48
|
+
return self.on("enable")(fn)
|
|
49
|
+
|
|
50
|
+
def on_disable(self, fn: LifecycleHandler) -> LifecycleHandler:
|
|
51
|
+
return self.on("disable")(fn)
|
|
52
|
+
|
|
53
|
+
def on_uninstall(self, fn: LifecycleHandler) -> LifecycleHandler:
|
|
54
|
+
return self.on("uninstall")(fn)
|
|
55
|
+
|
|
56
|
+
async def run(self, event: LifecycleEvent, ctx: PluginContext) -> None:
|
|
57
|
+
for handler in self._handlers[event]:
|
|
58
|
+
await handler(ctx)
|
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from dataclasses import dataclass, field
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
6
8
|
from typing import Any
|
|
7
9
|
|
|
8
10
|
from fastapi import Depends, Request
|
|
9
11
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
12
|
|
|
13
|
+
from palette_sdk.data_rooms import DataRoomsClient
|
|
14
|
+
|
|
11
15
|
|
|
12
16
|
@dataclass
|
|
13
17
|
class PluginContext:
|
|
@@ -34,7 +38,31 @@ class PluginContext:
|
|
|
34
38
|
plugin_id: str = ""
|
|
35
39
|
permissions: list[str] = field(default_factory=list)
|
|
36
40
|
storage: Any = None # Platform storage service injected at runtime
|
|
41
|
+
data_rooms: DataRoomsClient = field(default_factory=DataRoomsClient)
|
|
37
42
|
config: dict[str, Any] = field(default_factory=dict)
|
|
43
|
+
logger: logging.Logger = field(default_factory=lambda: logging.getLogger("palette_sdk.plugin"))
|
|
44
|
+
|
|
45
|
+
def has_permission(self, permission: str) -> bool:
|
|
46
|
+
return permission in self.permissions
|
|
47
|
+
|
|
48
|
+
def config_value(self, key: str, default: Any = None) -> Any:
|
|
49
|
+
return self.config.get(key, default)
|
|
50
|
+
|
|
51
|
+
def require_config(self, key: str) -> Any:
|
|
52
|
+
if key not in self.config or self.config[key] in (None, ""):
|
|
53
|
+
raise KeyError(f"missing plugin config value: {key}")
|
|
54
|
+
return self.config[key]
|
|
55
|
+
|
|
56
|
+
def secret(self, key: str, default: str | None = None) -> str | None:
|
|
57
|
+
secrets = self.config.get("secrets")
|
|
58
|
+
if isinstance(secrets, dict) and key in secrets:
|
|
59
|
+
return secrets[key]
|
|
60
|
+
return os.environ.get(key, default)
|
|
61
|
+
|
|
62
|
+
def repo(self, model: type[Any]):
|
|
63
|
+
from palette_sdk.repository import OrgRepository
|
|
64
|
+
|
|
65
|
+
return OrgRepository(self.db, model, self.organization_id)
|
|
38
66
|
|
|
39
67
|
|
|
40
68
|
async def get_plugin_context(request: Request) -> PluginContext:
|
|
@@ -59,5 +87,7 @@ async def get_plugin_context(request: Request) -> PluginContext:
|
|
|
59
87
|
plugin_id=getattr(state, "plugin_id", ""),
|
|
60
88
|
permissions=getattr(state, "plugin_permissions", []),
|
|
61
89
|
storage=getattr(state, "storage", None),
|
|
90
|
+
data_rooms=DataRoomsClient(getattr(state, "data_rooms", None)),
|
|
62
91
|
config=getattr(state, "plugin_config", {}),
|
|
92
|
+
logger=getattr(state, "plugin_logger", logging.getLogger(f"palette_sdk.plugin.{getattr(state, 'plugin_id', 'unknown')}")),
|
|
63
93
|
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Org-safe repository helpers for plugin-owned data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from typing import Any, Generic, TypeVar
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import asc, desc, func, select
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OrgRepository(Generic[T]):
|
|
15
|
+
"""Small CRUD helper for plugin models.
|
|
16
|
+
|
|
17
|
+
The platform still enforces plugin schema isolation and database RLS in
|
|
18
|
+
production. This helper adds the same organization guard in app code, which
|
|
19
|
+
keeps local SQLite/dev tests honest and reduces repeated query boilerplate.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, db: AsyncSession, model: type[T], organization_id: int):
|
|
23
|
+
self.db = db
|
|
24
|
+
self.model = model
|
|
25
|
+
self.organization_id = organization_id
|
|
26
|
+
|
|
27
|
+
def _org_filter(self):
|
|
28
|
+
if hasattr(self.model, "organization_id"):
|
|
29
|
+
return self.model.organization_id == self.organization_id
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
def _base_select(self):
|
|
33
|
+
stmt = select(self.model)
|
|
34
|
+
org_filter = self._org_filter()
|
|
35
|
+
if org_filter is not None:
|
|
36
|
+
stmt = stmt.where(org_filter)
|
|
37
|
+
return stmt
|
|
38
|
+
|
|
39
|
+
def _apply_filters(self, stmt, filters: Mapping[str, Any] | None):
|
|
40
|
+
for key, value in (filters or {}).items():
|
|
41
|
+
if not hasattr(self.model, key):
|
|
42
|
+
raise ValueError(f"{self.model.__name__} has no column {key!r}")
|
|
43
|
+
stmt = stmt.where(getattr(self.model, key) == value)
|
|
44
|
+
return stmt
|
|
45
|
+
|
|
46
|
+
def _apply_order(self, stmt, order_by: str | Sequence[str] | None):
|
|
47
|
+
if order_by is None:
|
|
48
|
+
return stmt
|
|
49
|
+
fields = [order_by] if isinstance(order_by, str) else list(order_by)
|
|
50
|
+
clauses = []
|
|
51
|
+
for field in fields:
|
|
52
|
+
direction = desc if field.startswith("-") else asc
|
|
53
|
+
key = field[1:] if field.startswith("-") else field
|
|
54
|
+
if not hasattr(self.model, key):
|
|
55
|
+
raise ValueError(f"{self.model.__name__} has no column {key!r}")
|
|
56
|
+
clauses.append(direction(getattr(self.model, key)))
|
|
57
|
+
return stmt.order_by(*clauses)
|
|
58
|
+
|
|
59
|
+
async def list(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
filters: Mapping[str, Any] | None = None,
|
|
63
|
+
order_by: str | Sequence[str] | None = None,
|
|
64
|
+
limit: int = 100,
|
|
65
|
+
offset: int = 0,
|
|
66
|
+
) -> list[T]:
|
|
67
|
+
stmt = self._apply_filters(self._base_select(), filters)
|
|
68
|
+
stmt = self._apply_order(stmt, order_by)
|
|
69
|
+
stmt = stmt.limit(max(1, min(limit, 500))).offset(max(0, offset))
|
|
70
|
+
return list((await self.db.execute(stmt)).scalars().all())
|
|
71
|
+
|
|
72
|
+
async def count(self, *, filters: Mapping[str, Any] | None = None) -> int:
|
|
73
|
+
stmt = select(func.count()).select_from(self.model)
|
|
74
|
+
org_filter = self._org_filter()
|
|
75
|
+
if org_filter is not None:
|
|
76
|
+
stmt = stmt.where(org_filter)
|
|
77
|
+
stmt = self._apply_filters(stmt, filters)
|
|
78
|
+
return int((await self.db.execute(stmt)).scalar_one())
|
|
79
|
+
|
|
80
|
+
async def get(self, id: Any) -> T | None:
|
|
81
|
+
stmt = self._base_select().where(self.model.id == id)
|
|
82
|
+
return (await self.db.execute(stmt)).scalar_one_or_none()
|
|
83
|
+
|
|
84
|
+
async def require(self, id: Any) -> T:
|
|
85
|
+
row = await self.get(id)
|
|
86
|
+
if row is None:
|
|
87
|
+
raise LookupError(f"{self.model.__name__} {id!r} not found")
|
|
88
|
+
return row
|
|
89
|
+
|
|
90
|
+
async def create(self, **values: Any) -> T:
|
|
91
|
+
if hasattr(self.model, "organization_id"):
|
|
92
|
+
values["organization_id"] = self.organization_id
|
|
93
|
+
row = self.model(**values)
|
|
94
|
+
self.db.add(row)
|
|
95
|
+
await self.db.commit()
|
|
96
|
+
await self.db.refresh(row)
|
|
97
|
+
return row
|
|
98
|
+
|
|
99
|
+
async def update(self, id: Any, **values: Any) -> T | None:
|
|
100
|
+
row = await self.get(id)
|
|
101
|
+
if row is None:
|
|
102
|
+
return None
|
|
103
|
+
values.pop("id", None)
|
|
104
|
+
values.pop("organization_id", None)
|
|
105
|
+
for key, value in values.items():
|
|
106
|
+
if not hasattr(row, key):
|
|
107
|
+
raise ValueError(f"{self.model.__name__} has no attribute {key!r}")
|
|
108
|
+
setattr(row, key, value)
|
|
109
|
+
await self.db.commit()
|
|
110
|
+
await self.db.refresh(row)
|
|
111
|
+
return row
|
|
112
|
+
|
|
113
|
+
async def delete(self, id: Any) -> bool:
|
|
114
|
+
row = await self.get(id)
|
|
115
|
+
if row is None:
|
|
116
|
+
return False
|
|
117
|
+
await self.db.delete(row)
|
|
118
|
+
await self.db.commit()
|
|
119
|
+
return True
|
package/lib/dev-simulator.js
CHANGED
|
@@ -250,9 +250,11 @@ const platform = {
|
|
|
250
250
|
org_role: "owner",
|
|
251
251
|
},
|
|
252
252
|
organizationId: 1,
|
|
253
|
+
pluginId: ${JSON.stringify(manifest.id)},
|
|
253
254
|
orgRole: "owner",
|
|
254
255
|
orgs: [{ id: 1, name: "Local Dev Org", slug: "local-dev", theme_id: "default", logo_url: null }],
|
|
255
256
|
agents: [],
|
|
257
|
+
permissions: ${JSON.stringify(manifest.permissions || [])},
|
|
256
258
|
apiFetch,
|
|
257
259
|
navigate: (path) => window.history.pushState(null, "", path),
|
|
258
260
|
showToast: (message, type = "info") => {
|
package/package.json
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from fastapi import Depends, HTTPException
|
|
4
4
|
from pydantic import BaseModel
|
|
5
|
-
from sqlalchemy import select
|
|
6
|
-
|
|
7
5
|
from palette_sdk import PluginRouter, PluginContext, get_plugin_context, require_permission
|
|
8
6
|
from models import Note
|
|
9
7
|
|
|
@@ -16,11 +14,7 @@ class NoteIn(BaseModel):
|
|
|
16
14
|
|
|
17
15
|
@router.get("/notes", dependencies=[require_permission("resources:read")])
|
|
18
16
|
async def list_notes(ctx: PluginContext = Depends(get_plugin_context)) -> list[dict]:
|
|
19
|
-
rows = (
|
|
20
|
-
await ctx.db.execute(
|
|
21
|
-
select(Note).order_by(Note.id.desc())
|
|
22
|
-
)
|
|
23
|
-
).scalars().all()
|
|
17
|
+
rows = await ctx.repo(Note).list(order_by="-id")
|
|
24
18
|
return [{"id": r.id, "body": r.body} for r in rows]
|
|
25
19
|
|
|
26
20
|
|
|
@@ -31,8 +25,5 @@ async def create_note(
|
|
|
31
25
|
) -> dict:
|
|
32
26
|
if not body.body.strip():
|
|
33
27
|
raise HTTPException(status_code=400, detail="body required")
|
|
34
|
-
note = Note(
|
|
35
|
-
ctx.db.add(note)
|
|
36
|
-
await ctx.db.commit()
|
|
37
|
-
await ctx.db.refresh(note)
|
|
28
|
+
note = await ctx.repo(Note).create(body=body.body)
|
|
38
29
|
return {"id": note.id, "body": note.body}
|