framework-m 0.7.3__tar.gz → 0.8.0__tar.gz
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.
- {framework_m-0.7.3 → framework_m-0.8.0}/CHANGELOG.md +11 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/PKG-INFO +3 -3
- {framework_m-0.7.3 → framework_m-0.8.0}/pyproject.toml +7 -4
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/conftest.py +1 -1
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/core/test_correction_service.py +98 -1
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/integration/test_migration_flow.py +2 -2
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/integration/test_workflow_lifecycle.py +31 -19
- framework_m-0.7.3/docs/ports.md +0 -414
- {framework_m-0.7.3 → framework_m-0.8.0}/.gitignore +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/LICENSE +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/README.md +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/alembic/README +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/alembic/env.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/alembic/script.py.mako +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/alembic.ini +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/src/framework_m/__init__.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/src/framework_m/cli/__init__.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/src/framework_m/cli/main.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/src/framework_m/core/services/__init__.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/src/framework_m/core/services/correction.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/src/framework_m/py.typed +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/adapters/auth/test_oauth_adapter.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/adapters/read_model/test_projector.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/architecture/fixtures/ci_dummy_index.html +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/architecture/test_frontend_bootstrap.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/architecture/test_package_separation.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/core/interfaces/test_read_model.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/core/interfaces/test_report_engine.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/helpers/__init__.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/integration/__init__.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/integration/test_api_endpoints.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/integration/test_app_ports.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/integration/test_child_table_integration.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/integration/test_crud_flow.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/integration/test_jobs_events_webhooks.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/integration/test_override_migration.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/integration/test_postgres_flow.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/integration/test_real_http_client.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/integration/test_rfc_0008_audit.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/integration/test_rfc_0008_correction.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/integration/test_rfc_0008_versioning.py +0 -0
- {framework_m-0.7.3 → framework_m-0.8.0}/tests/test_meta.py +0 -0
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
## framework-m v0.8.0
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
- pagination, tabbed layout and dynamic fields in desk (86b5e66)
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
- clarify use aiosqlite for db url in test (981c6c4)
|
|
10
|
+
- typecheck error (1e06770)
|
|
11
|
+
|
|
1
12
|
**framework-m** v0.7.3: internal dependency bump due to **framework-m-core** update**framework-m** v0.7.2: internal dependency bump due to **framework-m-core** update**framework-m** v0.7.1: internal dependency bump due to **framework-m-studio** update## framework-m v0.7.0
|
|
2
13
|
|
|
3
14
|
### Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: framework-m
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: A modular, metadata-driven business application framework
|
|
5
5
|
Project-URL: Homepage, https://gitlab.com/castlecraft/framework-m
|
|
6
6
|
Project-URL: Documentation, https://gitlab.com/castlecraft/framework-m#readme
|
|
@@ -18,8 +18,8 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
19
|
Classifier: Typing :: Typed
|
|
20
20
|
Requires-Python: >=3.12
|
|
21
|
-
Requires-Dist: framework-m-core>=0.
|
|
22
|
-
Requires-Dist: framework-m-standard>=0.
|
|
21
|
+
Requires-Dist: framework-m-core>=0.14.0
|
|
22
|
+
Requires-Dist: framework-m-standard>=0.11.0
|
|
23
23
|
Description-Content-Type: text/markdown
|
|
24
24
|
|
|
25
25
|
# Framework M
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "framework-m"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.0"
|
|
4
4
|
description = "A modular, metadata-driven business application framework"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "Apache-2.0" }
|
|
@@ -21,8 +21,8 @@ classifiers = [
|
|
|
21
21
|
]
|
|
22
22
|
|
|
23
23
|
dependencies = [
|
|
24
|
-
"framework-m-core>=0.
|
|
25
|
-
"framework-m-standard>=0.
|
|
24
|
+
"framework-m-core>=0.14.0",
|
|
25
|
+
"framework-m-standard>=0.11.0",
|
|
26
26
|
]
|
|
27
27
|
|
|
28
28
|
[tool.uv.sources]
|
|
@@ -83,8 +83,11 @@ filterwarnings = [
|
|
|
83
83
|
]
|
|
84
84
|
|
|
85
85
|
[tool.coverage.run]
|
|
86
|
-
source = ["src/framework_m"]
|
|
87
86
|
branch = true
|
|
87
|
+
omit = [
|
|
88
|
+
"*/tests/*",
|
|
89
|
+
"*conftest.py",
|
|
90
|
+
]
|
|
88
91
|
|
|
89
92
|
[tool.coverage.report]
|
|
90
93
|
exclude_lines = [
|
|
@@ -101,7 +101,7 @@ def configured_container() -> Container:
|
|
|
101
101
|
container = Container()
|
|
102
102
|
container.config.from_dict(
|
|
103
103
|
{
|
|
104
|
-
"database_url": "sqlite:///:memory:",
|
|
104
|
+
"database_url": "sqlite+aiosqlite:///:memory:",
|
|
105
105
|
"debug": True,
|
|
106
106
|
"app_name": "TestApp",
|
|
107
107
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from typing import Any
|
|
6
|
-
from unittest.mock import AsyncMock
|
|
6
|
+
from unittest.mock import AsyncMock, patch
|
|
7
7
|
|
|
8
8
|
import pytest
|
|
9
9
|
from framework_m_core.domain.base_doctype import BaseDocType, Field
|
|
@@ -11,6 +11,7 @@ from framework_m_core.domain.mixins import DocStatus, SubmittableMixin, Versione
|
|
|
11
11
|
from framework_m_core.interfaces.audit import AuditLogProtocol
|
|
12
12
|
from framework_m_core.interfaces.auth_context import UserContext
|
|
13
13
|
from framework_m_core.interfaces.repository import RepositoryProtocol
|
|
14
|
+
from pydantic import ConfigDict
|
|
14
15
|
|
|
15
16
|
from framework_m.core.services.correction import CorrectionService
|
|
16
17
|
|
|
@@ -26,6 +27,17 @@ class VersionedInvoice(Invoice, VersionedMixin):
|
|
|
26
27
|
"""Versioned invoice doctype for in-place correction tests."""
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
class PlainDoc(BaseDocType):
|
|
31
|
+
"""Plain doc without SubmittableMixin for edge cases."""
|
|
32
|
+
|
|
33
|
+
title: str = Field(default="Test")
|
|
34
|
+
model_config = ConfigDict(extra="ignore")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PlainVersionedDoc(PlainDoc, VersionedMixin):
|
|
38
|
+
"""Versioned plain doc for in-place edge cases."""
|
|
39
|
+
|
|
40
|
+
|
|
29
41
|
@pytest.fixture
|
|
30
42
|
def mock_repository() -> AsyncMock:
|
|
31
43
|
"""Repository mock implementing save semantics."""
|
|
@@ -239,6 +251,91 @@ class TestCorrectionServiceRestore:
|
|
|
239
251
|
user=user_context,
|
|
240
252
|
)
|
|
241
253
|
|
|
254
|
+
|
|
255
|
+
class TestCorrectionServiceEdgeCases:
|
|
256
|
+
"""Coverage tests for defensive checks and edge cases."""
|
|
257
|
+
|
|
258
|
+
def test_next_amended_name_unmatchable_pattern(
|
|
259
|
+
self, service: CorrectionService
|
|
260
|
+
) -> None:
|
|
261
|
+
"""Cover branch where regex match is None."""
|
|
262
|
+
assert service._next_amended_name("INV-001-A") == "INV-001-A-1"
|
|
263
|
+
|
|
264
|
+
def test_ensure_submitted_without_docstatus(
|
|
265
|
+
self, service: CorrectionService
|
|
266
|
+
) -> None:
|
|
267
|
+
"""Cover branch where doc has no docstatus attribute."""
|
|
268
|
+
doc = PlainDoc(name="PLAIN-1")
|
|
269
|
+
with pytest.raises(
|
|
270
|
+
ValueError, match="Only submitted documents can be corrected"
|
|
271
|
+
):
|
|
272
|
+
service._ensure_submitted(doc)
|
|
273
|
+
|
|
274
|
+
def test_clone_with_updates_keep_id_and_no_docstatus(
|
|
275
|
+
self, service: CorrectionService
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Cover branches for drop_id=False and missing docstatus field."""
|
|
278
|
+
doc = PlainDoc(name="PLAIN-1")
|
|
279
|
+
cloned = service._clone_with_updates(
|
|
280
|
+
source=doc,
|
|
281
|
+
new_name="PLAIN-2",
|
|
282
|
+
docstatus=DocStatus.DRAFT,
|
|
283
|
+
drop_id=False,
|
|
284
|
+
)
|
|
285
|
+
assert cloned.id == doc.id
|
|
286
|
+
assert cloned.name == "PLAIN-2"
|
|
287
|
+
assert not hasattr(cloned, "docstatus")
|
|
288
|
+
|
|
289
|
+
def test_document_ref_without_name(self, service: CorrectionService) -> None:
|
|
290
|
+
"""Cover branch where doc.name is None/empty."""
|
|
291
|
+
doc = PlainDoc(name="")
|
|
292
|
+
assert service._document_ref(doc) == str(doc.id)
|
|
293
|
+
|
|
294
|
+
@pytest.mark.asyncio
|
|
295
|
+
@patch.object(CorrectionService, "_ensure_submitted")
|
|
296
|
+
async def test_amend_without_copied_docstatus(
|
|
297
|
+
self,
|
|
298
|
+
mock_ensure: Any,
|
|
299
|
+
service: CorrectionService,
|
|
300
|
+
mock_repository: AsyncMock,
|
|
301
|
+
user_context: UserContext,
|
|
302
|
+
) -> None:
|
|
303
|
+
"""Cover hasattr(cancelled, 'docstatus') == False branch."""
|
|
304
|
+
doc = PlainDoc(name="PLAIN-1")
|
|
305
|
+
await service.amend(doc, "Test", user_context)
|
|
306
|
+
cancelled_doc = mock_repository.save.await_args_list[0].args[0]
|
|
307
|
+
assert not hasattr(cancelled_doc, "docstatus")
|
|
308
|
+
|
|
309
|
+
@pytest.mark.asyncio
|
|
310
|
+
@patch.object(CorrectionService, "_ensure_submitted")
|
|
311
|
+
async def test_restore_without_copied_docstatus(
|
|
312
|
+
self,
|
|
313
|
+
mock_ensure: Any,
|
|
314
|
+
service: CorrectionService,
|
|
315
|
+
mock_repository: AsyncMock,
|
|
316
|
+
user_context: UserContext,
|
|
317
|
+
) -> None:
|
|
318
|
+
"""Cover hasattr(cancelled, 'docstatus') == False branch."""
|
|
319
|
+
doc = PlainDoc(name="PLAIN-1")
|
|
320
|
+
await service.restore(doc, {"title": "Restored"}, "Test", user_context)
|
|
321
|
+
cancelled_doc = mock_repository.save.await_args_list[0].args[0]
|
|
322
|
+
assert not hasattr(cancelled_doc, "docstatus")
|
|
323
|
+
|
|
324
|
+
@pytest.mark.asyncio
|
|
325
|
+
@patch.object(CorrectionService, "_ensure_submitted")
|
|
326
|
+
async def test_in_place_edit_without_copied_docstatus(
|
|
327
|
+
self,
|
|
328
|
+
mock_ensure: Any,
|
|
329
|
+
service: CorrectionService,
|
|
330
|
+
mock_repository: AsyncMock,
|
|
331
|
+
user_context: UserContext,
|
|
332
|
+
) -> None:
|
|
333
|
+
"""Cover hasattr(editable, 'docstatus') == False branch."""
|
|
334
|
+
doc = PlainVersionedDoc(name="PLAIN-1")
|
|
335
|
+
await service.in_place_edit(doc, "Test", user_context)
|
|
336
|
+
editable_doc = mock_repository.save.await_args_list[1].args[0]
|
|
337
|
+
assert not hasattr(editable_doc, "docstatus")
|
|
338
|
+
|
|
242
339
|
@pytest.mark.asyncio
|
|
243
340
|
async def test_restore_raises_if_snapshot_empty(
|
|
244
341
|
self,
|
|
@@ -139,9 +139,9 @@ class TestMigrationFlow:
|
|
|
139
139
|
conn.execute(
|
|
140
140
|
text(
|
|
141
141
|
"INSERT INTO datadoc "
|
|
142
|
-
"(id, name, name_field, creation, modified, owner) "
|
|
142
|
+
"(id, name, name_field, creation, modified, owner, custom_fields) "
|
|
143
143
|
"VALUES ('test-uuid-1', 'DATA-001', 'Test Data', "
|
|
144
|
-
"datetime('now'), datetime('now'), 'test@test.com')"
|
|
144
|
+
"datetime('now'), datetime('now'), 'test@test.com', '{}')"
|
|
145
145
|
)
|
|
146
146
|
)
|
|
147
147
|
conn.commit()
|
|
@@ -50,7 +50,13 @@ class InMemoryWorkflowAdapter:
|
|
|
50
50
|
"from": "Draft",
|
|
51
51
|
"to": "Pending Approval",
|
|
52
52
|
"action": "submit",
|
|
53
|
-
"allowed_roles": ["Employee"
|
|
53
|
+
"allowed_roles": ["Employee"],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"from": "Draft",
|
|
57
|
+
"to": "Pending Approval",
|
|
58
|
+
"action": "submit",
|
|
59
|
+
"allowed_roles": ["Manager", "Admin"], # Admins can also submit
|
|
54
60
|
},
|
|
55
61
|
{
|
|
56
62
|
"from": "Pending Approval",
|
|
@@ -123,18 +129,15 @@ class InMemoryWorkflowAdapter:
|
|
|
123
129
|
current_state_info = self._states[key]
|
|
124
130
|
workflow_config = self._workflow_config[current_state_info.workflow_name]
|
|
125
131
|
|
|
126
|
-
# Find matching transition
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
transition = trans
|
|
134
|
-
break
|
|
132
|
+
# Find matching transition(s) for the action
|
|
133
|
+
possible_transitions = [
|
|
134
|
+
t
|
|
135
|
+
for t in workflow_config["transitions"]
|
|
136
|
+
if t["from"] == current_state_info.current_state
|
|
137
|
+
and t["action"] == request.action
|
|
138
|
+
]
|
|
135
139
|
|
|
136
|
-
|
|
137
|
-
if transition is None:
|
|
140
|
+
if not possible_transitions:
|
|
138
141
|
return TransitionResult(
|
|
139
142
|
success=False,
|
|
140
143
|
old_state=current_state_info.current_state,
|
|
@@ -143,22 +146,31 @@ class InMemoryWorkflowAdapter:
|
|
|
143
146
|
message=f"Invalid action '{request.action}' from state '{current_state_info.current_state}'",
|
|
144
147
|
)
|
|
145
148
|
|
|
146
|
-
#
|
|
149
|
+
# Find a transition the user is allowed to perform
|
|
147
150
|
user_roles = request.user.roles
|
|
148
|
-
|
|
151
|
+
allowed_transition = None
|
|
152
|
+
for trans in possible_transitions:
|
|
153
|
+
allowed_roles = trans.get("allowed_roles")
|
|
154
|
+
if not allowed_roles or any(r in user_roles for r in allowed_roles):
|
|
155
|
+
allowed_transition = trans
|
|
156
|
+
break
|
|
149
157
|
|
|
150
|
-
if not
|
|
158
|
+
if not allowed_transition:
|
|
159
|
+
# A transition exists, but user lacks permission
|
|
160
|
+
required_roles = {
|
|
161
|
+
r for t in possible_transitions for r in (t.get("allowed_roles") or [])
|
|
162
|
+
}
|
|
151
163
|
return TransitionResult(
|
|
152
164
|
success=False,
|
|
153
165
|
old_state=current_state_info.current_state,
|
|
154
166
|
new_state=current_state_info.current_state,
|
|
155
167
|
action=request.action,
|
|
156
|
-
message=f"User does not have required role. Required: {
|
|
168
|
+
message=f"User does not have required role. Required: {required_roles}, User has: {user_roles}",
|
|
157
169
|
)
|
|
158
170
|
|
|
159
171
|
# Perform transition
|
|
160
172
|
old_state = current_state_info.current_state
|
|
161
|
-
new_state =
|
|
173
|
+
new_state = allowed_transition["to"]
|
|
162
174
|
|
|
163
175
|
self._states[key] = current_state_info.model_copy(
|
|
164
176
|
update={
|
|
@@ -197,8 +209,8 @@ class InMemoryWorkflowAdapter:
|
|
|
197
209
|
continue
|
|
198
210
|
|
|
199
211
|
# Check if user has required role
|
|
200
|
-
allowed_roles = transition
|
|
201
|
-
if any(
|
|
212
|
+
allowed_roles = transition.get("allowed_roles")
|
|
213
|
+
if not allowed_roles or any(r in user_roles for r in allowed_roles):
|
|
202
214
|
available_actions.append(transition["action"])
|
|
203
215
|
|
|
204
216
|
return available_actions
|
framework_m-0.7.3/docs/ports.md
DELETED
|
@@ -1,414 +0,0 @@
|
|
|
1
|
-
# Protocol Interfaces (Ports)
|
|
2
|
-
|
|
3
|
-
Framework M defines clear protocol interfaces for all infrastructure concerns. This allows different implementations to be swapped without changing business logic.
|
|
4
|
-
|
|
5
|
-
## Overview
|
|
6
|
-
|
|
7
|
-
| Protocol | File | Purpose |
|
|
8
|
-
|----------|------|---------|
|
|
9
|
-
| `RepositoryProtocol` | `repository.py` | CRUD operations for documents |
|
|
10
|
-
| `EventBusProtocol` | `event_bus.py` | Publish/subscribe events |
|
|
11
|
-
| `AuthContextProtocol` | `auth_context.py` | Current user context |
|
|
12
|
-
| `PermissionProtocol` | `permission.py` | Authorization with RLS |
|
|
13
|
-
| `StorageProtocol` | `storage.py` | File storage abstraction |
|
|
14
|
-
| `JobQueueProtocol` | `job_queue.py` | Background job processing |
|
|
15
|
-
| `CacheProtocol` | `cache.py` | Caching layer |
|
|
16
|
-
| `NotificationProtocol` | `notification.py` | Email/SMS notifications |
|
|
17
|
-
| `SearchProtocol` | `search.py` | Full-text search |
|
|
18
|
-
| `PrintProtocol` | `print.py` | PDF/document generation |
|
|
19
|
-
| `I18nProtocol` | `i18n.py` | Internationalization |
|
|
20
|
-
|
|
21
|
-
---
|
|
22
|
-
|
|
23
|
-
## RepositoryProtocol
|
|
24
|
-
|
|
25
|
-
**Purpose**: Data access for documents with filtering, pagination, and optimistic concurrency.
|
|
26
|
-
|
|
27
|
-
```python
|
|
28
|
-
from typing import Protocol, Generic, TypeVar
|
|
29
|
-
|
|
30
|
-
T = TypeVar("T")
|
|
31
|
-
|
|
32
|
-
class RepositoryProtocol(Protocol[T]):
|
|
33
|
-
async def get(self, name: str) -> T | None:
|
|
34
|
-
"""Fetch a document by its name."""
|
|
35
|
-
...
|
|
36
|
-
|
|
37
|
-
async def save(self, doc: T, version: int | None = None) -> T:
|
|
38
|
-
"""Save a document with optional optimistic concurrency."""
|
|
39
|
-
...
|
|
40
|
-
|
|
41
|
-
async def delete(self, name: str) -> None:
|
|
42
|
-
"""Delete a document."""
|
|
43
|
-
...
|
|
44
|
-
|
|
45
|
-
async def exists(self, name: str) -> bool:
|
|
46
|
-
"""Check if a document exists."""
|
|
47
|
-
...
|
|
48
|
-
|
|
49
|
-
async def list(
|
|
50
|
-
self,
|
|
51
|
-
filters: FilterSpec | None = None,
|
|
52
|
-
order_by: list[OrderSpec] | None = None,
|
|
53
|
-
limit: int | None = None,
|
|
54
|
-
offset: int | None = None,
|
|
55
|
-
) -> PaginatedResult[T]:
|
|
56
|
-
"""List documents with filtering and pagination."""
|
|
57
|
-
...
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
**Key Types**:
|
|
61
|
-
- `FilterSpec`: Nested filter conditions
|
|
62
|
-
- `OrderSpec`: Sort specification (field, direction)
|
|
63
|
-
- `PaginatedResult`: Paginated response with items and total
|
|
64
|
-
|
|
65
|
-
---
|
|
66
|
-
|
|
67
|
-
## EventBusProtocol
|
|
68
|
-
|
|
69
|
-
**Purpose**: Publish/subscribe event system with CloudEvents format.
|
|
70
|
-
|
|
71
|
-
```python
|
|
72
|
-
class EventBusProtocol(Protocol):
|
|
73
|
-
async def publish(self, event: CloudEvent) -> None:
|
|
74
|
-
"""Publish an event to all subscribers."""
|
|
75
|
-
...
|
|
76
|
-
|
|
77
|
-
async def subscribe(
|
|
78
|
-
self,
|
|
79
|
-
pattern: str,
|
|
80
|
-
handler: EventHandler,
|
|
81
|
-
) -> str:
|
|
82
|
-
"""Subscribe to events matching a pattern."""
|
|
83
|
-
...
|
|
84
|
-
|
|
85
|
-
async def unsubscribe(self, subscription_id: str) -> None:
|
|
86
|
-
"""Remove a subscription."""
|
|
87
|
-
...
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
**Event Types**:
|
|
91
|
-
- `doc.created` - Document created
|
|
92
|
-
- `doc.updated` - Document modified
|
|
93
|
-
- `doc.deleted` - Document deleted
|
|
94
|
-
- `workflow.transition` - Workflow state change
|
|
95
|
-
|
|
96
|
-
---
|
|
97
|
-
|
|
98
|
-
## AuthContextProtocol
|
|
99
|
-
|
|
100
|
-
**Purpose**: Access to current user, roles, and tenant information.
|
|
101
|
-
|
|
102
|
-
```python
|
|
103
|
-
class AuthContextProtocol(Protocol):
|
|
104
|
-
@property
|
|
105
|
-
def user(self) -> UserContext | None:
|
|
106
|
-
"""Get current user or None for anonymous."""
|
|
107
|
-
...
|
|
108
|
-
|
|
109
|
-
def has_role(self, role: str) -> bool:
|
|
110
|
-
"""Check if user has a specific role."""
|
|
111
|
-
...
|
|
112
|
-
|
|
113
|
-
def is_system_user(self) -> bool:
|
|
114
|
-
"""Check if running as system (bypass permissions)."""
|
|
115
|
-
...
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
**UserContext**:
|
|
119
|
-
```python
|
|
120
|
-
class UserContext:
|
|
121
|
-
user_id: str
|
|
122
|
-
email: str
|
|
123
|
-
full_name: str
|
|
124
|
-
roles: list[str]
|
|
125
|
-
tenant_id: str | None
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
---
|
|
129
|
-
|
|
130
|
-
## PermissionProtocol
|
|
131
|
-
|
|
132
|
-
**Purpose**: Check permissions and generate Row-Level Security filters.
|
|
133
|
-
|
|
134
|
-
```python
|
|
135
|
-
class PermissionProtocol(Protocol):
|
|
136
|
-
async def can(
|
|
137
|
-
self,
|
|
138
|
-
action: PermissionAction,
|
|
139
|
-
doctype: str,
|
|
140
|
-
doc: BaseDocType | None = None,
|
|
141
|
-
) -> bool:
|
|
142
|
-
"""Check if user can perform action."""
|
|
143
|
-
...
|
|
144
|
-
|
|
145
|
-
async def get_permitted_filters(
|
|
146
|
-
self,
|
|
147
|
-
doctype: str,
|
|
148
|
-
action: PermissionAction,
|
|
149
|
-
) -> FilterSpec:
|
|
150
|
-
"""Get RLS filters for list queries."""
|
|
151
|
-
...
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
**PermissionAction**: `read`, `write`, `create`, `delete`, `submit`, `cancel`
|
|
155
|
-
|
|
156
|
-
---
|
|
157
|
-
|
|
158
|
-
## StorageProtocol
|
|
159
|
-
|
|
160
|
-
**Purpose**: File storage with presigned URLs and metadata.
|
|
161
|
-
|
|
162
|
-
```python
|
|
163
|
-
class StorageProtocol(Protocol):
|
|
164
|
-
async def save_file(
|
|
165
|
-
self,
|
|
166
|
-
key: str,
|
|
167
|
-
content: bytes | AsyncIterable[bytes],
|
|
168
|
-
content_type: str | None = None,
|
|
169
|
-
) -> FileMetadata:
|
|
170
|
-
"""Save a file."""
|
|
171
|
-
...
|
|
172
|
-
|
|
173
|
-
async def get_file(self, key: str) -> bytes:
|
|
174
|
-
"""Retrieve file content."""
|
|
175
|
-
...
|
|
176
|
-
|
|
177
|
-
async def get_url(
|
|
178
|
-
self,
|
|
179
|
-
key: str,
|
|
180
|
-
expires_in: int = 3600,
|
|
181
|
-
) -> str:
|
|
182
|
-
"""Get a presigned URL for direct access."""
|
|
183
|
-
...
|
|
184
|
-
|
|
185
|
-
async def delete_file(self, key: str) -> None:
|
|
186
|
-
"""Delete a file."""
|
|
187
|
-
...
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
---
|
|
191
|
-
|
|
192
|
-
## JobQueueProtocol
|
|
193
|
-
|
|
194
|
-
**Purpose**: Enqueue background jobs with scheduling and retry.
|
|
195
|
-
|
|
196
|
-
```python
|
|
197
|
-
class JobQueueProtocol(Protocol):
|
|
198
|
-
async def enqueue(
|
|
199
|
-
self,
|
|
200
|
-
job_name: str,
|
|
201
|
-
*args: Any,
|
|
202
|
-
defer_by: int | None = None,
|
|
203
|
-
**kwargs: Any,
|
|
204
|
-
) -> str:
|
|
205
|
-
"""Enqueue a job for background execution."""
|
|
206
|
-
...
|
|
207
|
-
|
|
208
|
-
async def schedule(
|
|
209
|
-
self,
|
|
210
|
-
job_name: str,
|
|
211
|
-
cron: str,
|
|
212
|
-
*args: Any,
|
|
213
|
-
**kwargs: Any,
|
|
214
|
-
) -> str:
|
|
215
|
-
"""Schedule a recurring job."""
|
|
216
|
-
...
|
|
217
|
-
|
|
218
|
-
async def cancel(self, job_id: str) -> bool:
|
|
219
|
-
"""Cancel a pending job."""
|
|
220
|
-
...
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
---
|
|
224
|
-
|
|
225
|
-
## CacheProtocol
|
|
226
|
-
|
|
227
|
-
**Purpose**: Key-value caching with TTL and pattern operations.
|
|
228
|
-
|
|
229
|
-
```python
|
|
230
|
-
class CacheProtocol(Protocol):
|
|
231
|
-
async def get(self, key: str) -> Any | None:
|
|
232
|
-
"""Get a cached value."""
|
|
233
|
-
...
|
|
234
|
-
|
|
235
|
-
async def set(
|
|
236
|
-
self,
|
|
237
|
-
key: str,
|
|
238
|
-
value: Any,
|
|
239
|
-
ttl: int | None = None,
|
|
240
|
-
) -> None:
|
|
241
|
-
"""Set a cached value with optional TTL."""
|
|
242
|
-
...
|
|
243
|
-
|
|
244
|
-
async def delete(self, key: str) -> None:
|
|
245
|
-
"""Delete a cached value."""
|
|
246
|
-
...
|
|
247
|
-
|
|
248
|
-
async def delete_pattern(self, pattern: str) -> int:
|
|
249
|
-
"""Delete all keys matching a pattern."""
|
|
250
|
-
...
|
|
251
|
-
```
|
|
252
|
-
|
|
253
|
-
---
|
|
254
|
-
|
|
255
|
-
## NotificationProtocol
|
|
256
|
-
|
|
257
|
-
**Purpose**: Send notifications via email, SMS, push, etc.
|
|
258
|
-
|
|
259
|
-
```python
|
|
260
|
-
class NotificationProtocol(Protocol):
|
|
261
|
-
async def send(
|
|
262
|
-
self,
|
|
263
|
-
notification: Notification,
|
|
264
|
-
) -> str:
|
|
265
|
-
"""Send a notification."""
|
|
266
|
-
...
|
|
267
|
-
|
|
268
|
-
async def send_bulk(
|
|
269
|
-
self,
|
|
270
|
-
notifications: list[Notification],
|
|
271
|
-
) -> list[str]:
|
|
272
|
-
"""Send multiple notifications."""
|
|
273
|
-
...
|
|
274
|
-
```
|
|
275
|
-
|
|
276
|
-
**Notification Types**: `email`, `sms`, `push`, `in_app`
|
|
277
|
-
|
|
278
|
-
---
|
|
279
|
-
|
|
280
|
-
## SearchProtocol
|
|
281
|
-
|
|
282
|
-
**Purpose**: Full-text search with facets and highlighting.
|
|
283
|
-
|
|
284
|
-
```python
|
|
285
|
-
class SearchProtocol(Protocol):
|
|
286
|
-
async def index(
|
|
287
|
-
self,
|
|
288
|
-
doctype: str,
|
|
289
|
-
doc_id: str,
|
|
290
|
-
data: dict[str, Any],
|
|
291
|
-
) -> None:
|
|
292
|
-
"""Index a document for search."""
|
|
293
|
-
...
|
|
294
|
-
|
|
295
|
-
async def search(
|
|
296
|
-
self,
|
|
297
|
-
query: str,
|
|
298
|
-
doctypes: list[str] | None = None,
|
|
299
|
-
filters: FilterSpec | None = None,
|
|
300
|
-
limit: int = 20,
|
|
301
|
-
) -> SearchResult:
|
|
302
|
-
"""Search documents."""
|
|
303
|
-
...
|
|
304
|
-
|
|
305
|
-
async def delete_index(
|
|
306
|
-
self,
|
|
307
|
-
doctype: str,
|
|
308
|
-
doc_id: str,
|
|
309
|
-
) -> None:
|
|
310
|
-
"""Remove a document from search index."""
|
|
311
|
-
...
|
|
312
|
-
```
|
|
313
|
-
|
|
314
|
-
---
|
|
315
|
-
|
|
316
|
-
## PrintProtocol
|
|
317
|
-
|
|
318
|
-
**Purpose**: Generate PDF/HTML documents from templates.
|
|
319
|
-
|
|
320
|
-
```python
|
|
321
|
-
class PrintProtocol(Protocol):
|
|
322
|
-
async def render(
|
|
323
|
-
self,
|
|
324
|
-
template: str,
|
|
325
|
-
data: dict[str, Any],
|
|
326
|
-
format: Literal["pdf", "html"] = "pdf",
|
|
327
|
-
) -> bytes:
|
|
328
|
-
"""Render a template to PDF or HTML."""
|
|
329
|
-
...
|
|
330
|
-
|
|
331
|
-
async def get_template(self, name: str) -> Template:
|
|
332
|
-
"""Get a print template by name."""
|
|
333
|
-
...
|
|
334
|
-
```
|
|
335
|
-
|
|
336
|
-
---
|
|
337
|
-
|
|
338
|
-
## I18nProtocol
|
|
339
|
-
|
|
340
|
-
**Purpose**: Internationalization and localization.
|
|
341
|
-
|
|
342
|
-
```python
|
|
343
|
-
class I18nProtocol(Protocol):
|
|
344
|
-
def translate(
|
|
345
|
-
self,
|
|
346
|
-
key: str,
|
|
347
|
-
locale: str | None = None,
|
|
348
|
-
**kwargs: Any,
|
|
349
|
-
) -> str:
|
|
350
|
-
"""Translate a key to the target locale."""
|
|
351
|
-
...
|
|
352
|
-
|
|
353
|
-
def get_locale(self) -> str:
|
|
354
|
-
"""Get the current locale."""
|
|
355
|
-
...
|
|
356
|
-
|
|
357
|
-
def list_locales(self) -> list[str]:
|
|
358
|
-
"""List available locales."""
|
|
359
|
-
...
|
|
360
|
-
```
|
|
361
|
-
|
|
362
|
-
---
|
|
363
|
-
|
|
364
|
-
## Using Protocols
|
|
365
|
-
|
|
366
|
-
### In Business Logic
|
|
367
|
-
|
|
368
|
-
```python
|
|
369
|
-
from framework_m.core.interfaces import RepositoryProtocol
|
|
370
|
-
|
|
371
|
-
class InvoiceService:
|
|
372
|
-
def __init__(self, repo: RepositoryProtocol[Invoice]) -> None:
|
|
373
|
-
self.repo = repo
|
|
374
|
-
|
|
375
|
-
async def create_invoice(self, data: dict) -> Invoice:
|
|
376
|
-
invoice = Invoice(**data)
|
|
377
|
-
return await self.repo.save(invoice)
|
|
378
|
-
```
|
|
379
|
-
|
|
380
|
-
### With Dependency Injection
|
|
381
|
-
|
|
382
|
-
```python
|
|
383
|
-
from dependency_injector.wiring import inject, Provide
|
|
384
|
-
from framework_m.core.container import Container
|
|
385
|
-
from framework_m.core.interfaces import RepositoryProtocol
|
|
386
|
-
|
|
387
|
-
@inject
|
|
388
|
-
async def get_invoices(
|
|
389
|
-
repo: RepositoryProtocol = Provide[Container.repository],
|
|
390
|
-
) -> list[Invoice]:
|
|
391
|
-
result = await repo.list()
|
|
392
|
-
return result.items
|
|
393
|
-
```
|
|
394
|
-
|
|
395
|
-
### In Testing
|
|
396
|
-
|
|
397
|
-
```python
|
|
398
|
-
class MockRepository:
|
|
399
|
-
def __init__(self):
|
|
400
|
-
self.data = {}
|
|
401
|
-
|
|
402
|
-
async def get(self, name: str) -> Invoice | None:
|
|
403
|
-
return self.data.get(name)
|
|
404
|
-
|
|
405
|
-
async def save(self, doc: Invoice) -> Invoice:
|
|
406
|
-
self.data[doc.name] = doc
|
|
407
|
-
return doc
|
|
408
|
-
|
|
409
|
-
# In tests
|
|
410
|
-
def test_invoice_creation():
|
|
411
|
-
repo = MockRepository()
|
|
412
|
-
service = InvoiceService(repo)
|
|
413
|
-
# ...
|
|
414
|
-
```
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|