kanban-core 2.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.
- kanban/__init__.py +26 -0
- kanban/events.py +128 -0
- kanban/exceptions.py +52 -0
- kanban/facade.py +262 -0
- kanban/models.py +102 -0
- kanban/permissions.py +64 -0
- kanban/policies.py +66 -0
- kanban/repositories.py +116 -0
- kanban/services/__init__.py +19 -0
- kanban/services/automation.py +20 -0
- kanban/services/board_admin.py +261 -0
- kanban/services/card_workflow.py +245 -0
- kanban/services/core.py +294 -0
- kanban/services/search.py +47 -0
- kanban/spi/__init__.py +49 -0
- kanban/spi/automation.py +49 -0
- kanban/unit_of_work.py +28 -0
- kanban_adapters/__init__.py +7 -0
- kanban_adapters/events.py +54 -0
- kanban_adapters/repositories.py +50 -0
- kanban_adapters/synchronous.py +52 -0
- kanban_core-2.1.0.dist-info/METADATA +177 -0
- kanban_core-2.1.0.dist-info/RECORD +24 -0
- kanban_core-2.1.0.dist-info/WHEEL +4 -0
kanban/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Public API for the kanban business module."""
|
|
2
|
+
|
|
3
|
+
from .exceptions import (
|
|
4
|
+
ConcurrencyError,
|
|
5
|
+
InvalidPersistenceStateError,
|
|
6
|
+
KanbanError,
|
|
7
|
+
NotFoundError,
|
|
8
|
+
PermissionError,
|
|
9
|
+
ValidationError,
|
|
10
|
+
)
|
|
11
|
+
from .facade import BoardView, CardView, ColumnView, KanbanFacade
|
|
12
|
+
|
|
13
|
+
__version__ = "2.1.0"
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"BoardView",
|
|
17
|
+
"CardView",
|
|
18
|
+
"ColumnView",
|
|
19
|
+
"ConcurrencyError",
|
|
20
|
+
"InvalidPersistenceStateError",
|
|
21
|
+
"KanbanError",
|
|
22
|
+
"KanbanFacade",
|
|
23
|
+
"NotFoundError",
|
|
24
|
+
"PermissionError",
|
|
25
|
+
"ValidationError",
|
|
26
|
+
]
|
kanban/events.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator, Mapping
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from dataclasses import dataclass, field, fields, is_dataclass
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any, Protocol
|
|
9
|
+
|
|
10
|
+
# ============= EVENTS SYSTEM =============
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EventRegistry:
|
|
14
|
+
"""Singleton-by-explicit-dependency; avoids module-global state."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, builtin: dict[str, str] | None = None):
|
|
17
|
+
self._events: dict[str, str] = dict(builtin) if builtin is not None else {}
|
|
18
|
+
|
|
19
|
+
def register(self, code: str, description: str) -> None:
|
|
20
|
+
if code in self._events:
|
|
21
|
+
raise ValueError(f"Event {code!r} already registered")
|
|
22
|
+
self._events[code] = description
|
|
23
|
+
|
|
24
|
+
def __contains__(self, code: str) -> bool:
|
|
25
|
+
return code in self._events
|
|
26
|
+
|
|
27
|
+
def __iter__(self) -> Iterator[str]:
|
|
28
|
+
yield from self._events.keys()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
builtin_events = {
|
|
32
|
+
"board.created": "Board created",
|
|
33
|
+
"board.updated": "Board updated",
|
|
34
|
+
"board.deleted": "Board deleted",
|
|
35
|
+
"column.created": "Column created",
|
|
36
|
+
"column.updated": "Column updated",
|
|
37
|
+
"column.deleted": "Column deleted",
|
|
38
|
+
"column.moved": "Column moved",
|
|
39
|
+
"card.created": "Card created",
|
|
40
|
+
"card.updated": "Card updated",
|
|
41
|
+
"card.deleted": "Card deleted",
|
|
42
|
+
"card.moved": "Card moved",
|
|
43
|
+
"card.blocked": "Card blocked",
|
|
44
|
+
"card.unblocked": "Card unblocked",
|
|
45
|
+
"wip.limit.reached": "WIP limit reached",
|
|
46
|
+
"workflow.transition": "Workflow transition",
|
|
47
|
+
"automation.triggered": "Automation triggered",
|
|
48
|
+
"automation.failed": "Automation failed",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
CORE_EVENTS = EventRegistry(builtin_events)
|
|
52
|
+
CARD_MOVED = "card.moved"
|
|
53
|
+
CARD_UPDATED = "card.updated"
|
|
54
|
+
CARD_CREATED = "card.created"
|
|
55
|
+
CARD_DELETED = "card.deleted"
|
|
56
|
+
COLUMN_UPDATED = "column.updated"
|
|
57
|
+
COLUMN_CREATED = "column.created"
|
|
58
|
+
COLUMN_DELETED = "column.deleted"
|
|
59
|
+
BOARD_CREATED = "board.created"
|
|
60
|
+
BOARD_UPDATED = "board.updated"
|
|
61
|
+
BOARD_DELETED = "board.deleted"
|
|
62
|
+
CARD_BLOCKED = "card.blocked"
|
|
63
|
+
CARD_UNBLOCKED = "card.unblocked"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class Event:
|
|
68
|
+
"""Immutable event object. Handlers receive a copy."""
|
|
69
|
+
|
|
70
|
+
type: str
|
|
71
|
+
entity_id: str
|
|
72
|
+
entity_type: str
|
|
73
|
+
id: str
|
|
74
|
+
timestamp: datetime
|
|
75
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
76
|
+
user_id: str | None = None
|
|
77
|
+
schema_version: int = 1
|
|
78
|
+
correlation_id: str | None = None
|
|
79
|
+
causation_id: str | None = None
|
|
80
|
+
aggregate_version: int | None = None
|
|
81
|
+
|
|
82
|
+
def __post_init__(self) -> None:
|
|
83
|
+
# A frozen dataclass does not make nested dictionaries immutable. Detach the
|
|
84
|
+
# payload from the caller so later mutations cannot rewrite event history.
|
|
85
|
+
object.__setattr__(self, "data", deepcopy(self.data))
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> dict[str, Any]:
|
|
88
|
+
"""Converts the event to a dictionary with serializable types."""
|
|
89
|
+
|
|
90
|
+
def _serialize_value(v: Any) -> Any:
|
|
91
|
+
if isinstance(v, set):
|
|
92
|
+
return sorted((_serialize_value(item) for item in v), key=repr)
|
|
93
|
+
if isinstance(v, list | tuple):
|
|
94
|
+
return [_serialize_value(item) for item in v]
|
|
95
|
+
if isinstance(v, Mapping):
|
|
96
|
+
return {key: _serialize_value(value) for key, value in v.items()}
|
|
97
|
+
if isinstance(v, datetime):
|
|
98
|
+
return v.isoformat()
|
|
99
|
+
if isinstance(v, timedelta):
|
|
100
|
+
return v.total_seconds()
|
|
101
|
+
if isinstance(v, Enum):
|
|
102
|
+
return _serialize_value(v.value)
|
|
103
|
+
if is_dataclass(v) and not isinstance(v, type):
|
|
104
|
+
return {
|
|
105
|
+
item.name: _serialize_value(getattr(v, item.name))
|
|
106
|
+
for item in fields(v)
|
|
107
|
+
}
|
|
108
|
+
return v
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
"id": self.id,
|
|
112
|
+
"type": self.type,
|
|
113
|
+
"entity_id": self.entity_id,
|
|
114
|
+
"entity_type": self.entity_type,
|
|
115
|
+
"timestamp": self.timestamp.isoformat(),
|
|
116
|
+
"user_id": self.user_id,
|
|
117
|
+
"schema_version": self.schema_version,
|
|
118
|
+
"correlation_id": self.correlation_id,
|
|
119
|
+
"causation_id": self.causation_id,
|
|
120
|
+
"aggregate_version": self.aggregate_version,
|
|
121
|
+
"data": _serialize_value(self.data),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class EventPublisher(Protocol):
|
|
126
|
+
"""Outbound port used only after the aggregate commit succeeds."""
|
|
127
|
+
|
|
128
|
+
def publish(self, event: Event) -> None: ...
|
kanban/exceptions.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any, Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
# ============= TYPE ALIASES & EXCEPTIONS =============
|
|
9
|
+
|
|
10
|
+
Validator = Callable[[Any], None]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class KanbanError(Exception):
|
|
14
|
+
"""Base exception for all library errors."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ValidationError(KanbanError):
|
|
18
|
+
"""For business rule violations."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PermissionError(KanbanError):
|
|
22
|
+
"""For authorization failures."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NotFoundError(KanbanError):
|
|
26
|
+
"""For when an entity cannot be found."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConcurrencyError(KanbanError):
|
|
30
|
+
"""For optimistic locking failures (lost updates)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InvalidPersistenceStateError(KanbanError):
|
|
34
|
+
"""Persisted state violates business-module invariants."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@runtime_checkable
|
|
38
|
+
class Clock(Protocol):
|
|
39
|
+
def __call__(self) -> datetime: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def utc_clock() -> datetime:
|
|
43
|
+
return datetime.now(timezone.utc)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@runtime_checkable
|
|
47
|
+
class IDFactory(Protocol):
|
|
48
|
+
def __call__(self) -> str: ...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def uuid4_factory() -> str:
|
|
52
|
+
return str(uuid.uuid4())
|
kanban/facade.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .exceptions import Clock, IDFactory, utc_clock, uuid4_factory
|
|
8
|
+
from .models import Board, Card, Column
|
|
9
|
+
from .permissions import Permission, PermissionPolicy, SimplePermissionPolicy
|
|
10
|
+
from .policies import WorkflowPolicy
|
|
11
|
+
from .repositories import KanbanRepository, restore_board
|
|
12
|
+
from .services import (
|
|
13
|
+
BoardAdminService,
|
|
14
|
+
CardSearcher,
|
|
15
|
+
CardWorkflowService,
|
|
16
|
+
InMemoryCardSearcher,
|
|
17
|
+
SyncAutomationRunner,
|
|
18
|
+
_KanbanServiceCore,
|
|
19
|
+
)
|
|
20
|
+
from .spi.automation import AutomationRunner
|
|
21
|
+
from .unit_of_work import KanbanUnitOfWork
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class CardView:
|
|
26
|
+
id: str
|
|
27
|
+
column_id: str
|
|
28
|
+
title: str
|
|
29
|
+
description: str
|
|
30
|
+
position: int
|
|
31
|
+
blocked_by: frozenset[str]
|
|
32
|
+
blocks: frozenset[str]
|
|
33
|
+
assignee_ids: frozenset[str]
|
|
34
|
+
due_date: datetime | None
|
|
35
|
+
size: float | None
|
|
36
|
+
archived: bool
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class ColumnView:
|
|
41
|
+
id: str
|
|
42
|
+
name: str
|
|
43
|
+
position: int
|
|
44
|
+
card_ids: tuple[str, ...]
|
|
45
|
+
color: str | None
|
|
46
|
+
wip_limit: int | None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class BoardView:
|
|
51
|
+
id: str
|
|
52
|
+
version: int
|
|
53
|
+
name: str
|
|
54
|
+
description: str
|
|
55
|
+
columns: tuple[ColumnView, ...]
|
|
56
|
+
cards: tuple[CardView, ...]
|
|
57
|
+
created_at: datetime | None
|
|
58
|
+
updated_at: datetime | None
|
|
59
|
+
owner_id: str | None
|
|
60
|
+
team_ids: frozenset[str]
|
|
61
|
+
|
|
62
|
+
def column_named(self, name: str) -> ColumnView | None:
|
|
63
|
+
return next((column for column in self.columns if column.name == name), None)
|
|
64
|
+
|
|
65
|
+
def card(self, card_id: str) -> CardView | None:
|
|
66
|
+
return next((card for card in self.cards if card.id == card_id), None)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _card_view(card: Card, board: Board) -> CardView:
|
|
70
|
+
column = board.column_for_card(card.id)
|
|
71
|
+
return CardView(
|
|
72
|
+
id=card.id,
|
|
73
|
+
column_id=column.id,
|
|
74
|
+
title=card.title,
|
|
75
|
+
description=card.description,
|
|
76
|
+
position=column.card_ids.index(card.id),
|
|
77
|
+
blocked_by=frozenset(card.blocked_by),
|
|
78
|
+
blocks=frozenset(card.blocks),
|
|
79
|
+
assignee_ids=frozenset(card.assignee_ids),
|
|
80
|
+
due_date=card.due_date,
|
|
81
|
+
size=card.size,
|
|
82
|
+
archived=card.archived,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _column_view(column: Column) -> ColumnView:
|
|
87
|
+
return ColumnView(
|
|
88
|
+
id=column.id,
|
|
89
|
+
name=column.name,
|
|
90
|
+
position=column.position,
|
|
91
|
+
card_ids=tuple(column.card_ids),
|
|
92
|
+
color=column.color,
|
|
93
|
+
wip_limit=column.policy.wip_limit,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _board_view(board: Board) -> BoardView:
|
|
98
|
+
cards = [
|
|
99
|
+
card
|
|
100
|
+
for column in board.get_columns_ordered()
|
|
101
|
+
for card in board.get_cards_in_column(column.id)
|
|
102
|
+
]
|
|
103
|
+
return BoardView(
|
|
104
|
+
id=board.id,
|
|
105
|
+
version=board.version,
|
|
106
|
+
name=board.name,
|
|
107
|
+
description=board.description,
|
|
108
|
+
columns=tuple(_column_view(column) for column in board.get_columns_ordered()),
|
|
109
|
+
cards=tuple(_card_view(card, board) for card in cards),
|
|
110
|
+
created_at=board.created_at,
|
|
111
|
+
updated_at=board.updated_at,
|
|
112
|
+
owner_id=board.owner_id,
|
|
113
|
+
team_ids=frozenset(board.team_ids),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class KanbanFacade:
|
|
118
|
+
"""The complete public business capability surface for kanban-core."""
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
repository: KanbanRepository,
|
|
123
|
+
unit_of_work: KanbanUnitOfWork,
|
|
124
|
+
permission_checker: PermissionPolicy | None = None,
|
|
125
|
+
searcher: CardSearcher | None = None,
|
|
126
|
+
automation_runner: AutomationRunner | None = None,
|
|
127
|
+
clock: Clock | None = None,
|
|
128
|
+
id_factory: IDFactory | None = None,
|
|
129
|
+
workflow_policy: WorkflowPolicy | None = None,
|
|
130
|
+
) -> None:
|
|
131
|
+
core = _KanbanServiceCore(
|
|
132
|
+
repository=repository,
|
|
133
|
+
unit_of_work=unit_of_work,
|
|
134
|
+
permission_checker=permission_checker or SimplePermissionPolicy(),
|
|
135
|
+
searcher=searcher or InMemoryCardSearcher(),
|
|
136
|
+
automation_runner=automation_runner or SyncAutomationRunner(),
|
|
137
|
+
clock=clock or utc_clock,
|
|
138
|
+
id_factory=id_factory or uuid4_factory,
|
|
139
|
+
workflow_policy=workflow_policy,
|
|
140
|
+
)
|
|
141
|
+
self._core = core
|
|
142
|
+
self._admin = BoardAdminService(core)
|
|
143
|
+
self._cards = CardWorkflowService(core)
|
|
144
|
+
|
|
145
|
+
def create_board(
|
|
146
|
+
self, user_id: str, name: str, columns: list[str] | None = None
|
|
147
|
+
) -> BoardView:
|
|
148
|
+
return _board_view(self._admin.create_board(name, user_id, columns))
|
|
149
|
+
|
|
150
|
+
def get_board(self, user_id: str, board_id: str) -> BoardView:
|
|
151
|
+
board = self._core.get_board_and_check_permission(
|
|
152
|
+
board_id, user_id, Permission.VIEW_BOARD
|
|
153
|
+
)
|
|
154
|
+
return _board_view(board)
|
|
155
|
+
|
|
156
|
+
def list_boards(self, user_id: str) -> list[BoardView]:
|
|
157
|
+
boards = [restore_board(item) for item in self._core.repository.list_all()]
|
|
158
|
+
return [
|
|
159
|
+
_board_view(board)
|
|
160
|
+
for board in boards
|
|
161
|
+
if self._core.is_allowed(user_id, board, Permission.VIEW_BOARD)
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
def update_board(self, user_id: str, board_id: str, **changes: Any) -> BoardView:
|
|
165
|
+
return _board_view(self._admin.update_board(board_id, user_id, **changes))
|
|
166
|
+
|
|
167
|
+
def delete_board(self, user_id: str, board_id: str) -> None:
|
|
168
|
+
self._admin.delete_board(board_id, user_id)
|
|
169
|
+
|
|
170
|
+
def add_column(
|
|
171
|
+
self,
|
|
172
|
+
user_id: str,
|
|
173
|
+
board_id: str,
|
|
174
|
+
name: str,
|
|
175
|
+
position: int | None = None,
|
|
176
|
+
) -> ColumnView:
|
|
177
|
+
return _column_view(
|
|
178
|
+
self._admin.create_column(board_id, user_id, name, position)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def update_column(
|
|
182
|
+
self, user_id: str, board_id: str, column_id: str, **changes: Any
|
|
183
|
+
) -> ColumnView:
|
|
184
|
+
return _column_view(
|
|
185
|
+
self._admin.update_column(board_id, column_id, user_id, **changes)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def delete_column(self, user_id: str, board_id: str, column_id: str) -> None:
|
|
189
|
+
self._admin.delete_column(board_id, column_id, user_id)
|
|
190
|
+
|
|
191
|
+
def add_card(
|
|
192
|
+
self,
|
|
193
|
+
user_id: str,
|
|
194
|
+
board_id: str,
|
|
195
|
+
column_id: str,
|
|
196
|
+
title: str,
|
|
197
|
+
**fields: Any,
|
|
198
|
+
) -> CardView:
|
|
199
|
+
card = self._cards.create_card(board_id, column_id, title, user_id, **fields)
|
|
200
|
+
board = self._core.get_board_and_check_permission(
|
|
201
|
+
board_id, user_id, Permission.VIEW_BOARD
|
|
202
|
+
)
|
|
203
|
+
return _card_view(card, board)
|
|
204
|
+
|
|
205
|
+
def update_card(
|
|
206
|
+
self, user_id: str, board_id: str, card_id: str, **changes: Any
|
|
207
|
+
) -> CardView:
|
|
208
|
+
card = self._cards.update_card(board_id, card_id, user_id, **changes)
|
|
209
|
+
board = self._core.get_board_and_check_permission(
|
|
210
|
+
board_id, user_id, Permission.VIEW_BOARD
|
|
211
|
+
)
|
|
212
|
+
return _card_view(card, board)
|
|
213
|
+
|
|
214
|
+
def move_card(
|
|
215
|
+
self,
|
|
216
|
+
user_id: str,
|
|
217
|
+
board_id: str,
|
|
218
|
+
card_id: str,
|
|
219
|
+
to_column_id: str,
|
|
220
|
+
position: int | None = None,
|
|
221
|
+
) -> CardView:
|
|
222
|
+
card = self._cards.move_card(board_id, card_id, to_column_id, position, user_id)
|
|
223
|
+
board = self._core.get_board_and_check_permission(
|
|
224
|
+
board_id, user_id, Permission.VIEW_BOARD
|
|
225
|
+
)
|
|
226
|
+
return _card_view(card, board)
|
|
227
|
+
|
|
228
|
+
def delete_card(self, user_id: str, board_id: str, card_id: str) -> None:
|
|
229
|
+
self._cards.delete_card(board_id, card_id, user_id)
|
|
230
|
+
|
|
231
|
+
def block_card(
|
|
232
|
+
self,
|
|
233
|
+
user_id: str,
|
|
234
|
+
board_id: str,
|
|
235
|
+
card_to_block_id: str,
|
|
236
|
+
blocking_card_id: str,
|
|
237
|
+
) -> None:
|
|
238
|
+
self._cards.block_card_dependency(
|
|
239
|
+
board_id, blocking_card_id, card_to_block_id, user_id
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def unblock_card(
|
|
243
|
+
self,
|
|
244
|
+
user_id: str,
|
|
245
|
+
board_id: str,
|
|
246
|
+
card_to_unblock_id: str,
|
|
247
|
+
blocking_card_id: str,
|
|
248
|
+
) -> None:
|
|
249
|
+
self._cards.unblock_card_dependency(
|
|
250
|
+
board_id, blocking_card_id, card_to_unblock_id, user_id
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def search_cards(
|
|
254
|
+
self, user_id: str, board_id: str, **filters: Any
|
|
255
|
+
) -> list[CardView]:
|
|
256
|
+
board = self._core.get_board_and_check_permission(
|
|
257
|
+
board_id, user_id, Permission.VIEW_BOARD
|
|
258
|
+
)
|
|
259
|
+
card_ids = self._core.searcher.search(
|
|
260
|
+
self._core.search_context(board), **filters
|
|
261
|
+
)
|
|
262
|
+
return [_card_view(board.cards[card_id], board) for card_id in card_ids]
|
kanban/models.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .exceptions import ValidationError
|
|
8
|
+
|
|
9
|
+
# ============= CORE DOMAIN MODELS =============
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Card:
|
|
14
|
+
"""Core card/item entity."""
|
|
15
|
+
|
|
16
|
+
id: str
|
|
17
|
+
title: str = ""
|
|
18
|
+
description: str = ""
|
|
19
|
+
blocked_by: set[str] = field(default_factory=set)
|
|
20
|
+
blocks: set[str] = field(default_factory=set)
|
|
21
|
+
assignee_ids: set[str] = field(default_factory=set)
|
|
22
|
+
due_date: datetime | None = None
|
|
23
|
+
size: float | None = None
|
|
24
|
+
archived: bool = False
|
|
25
|
+
|
|
26
|
+
def is_blocked(self) -> bool:
|
|
27
|
+
return bool(self.blocked_by)
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> dict[str, Any]:
|
|
30
|
+
return {
|
|
31
|
+
"id": self.id,
|
|
32
|
+
"title": self.title,
|
|
33
|
+
"description": self.description,
|
|
34
|
+
"blocked_by": sorted(self.blocked_by),
|
|
35
|
+
"blocks": sorted(self.blocks),
|
|
36
|
+
"assignee_ids": sorted(self.assignee_ids),
|
|
37
|
+
"due_date": self.due_date.isoformat() if self.due_date else None,
|
|
38
|
+
"size": self.size,
|
|
39
|
+
"archived": self.archived,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ColumnPolicy:
|
|
45
|
+
"""Rules for column behavior."""
|
|
46
|
+
|
|
47
|
+
wip_limit: int | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Column:
|
|
52
|
+
"""Board column/list."""
|
|
53
|
+
|
|
54
|
+
id: str
|
|
55
|
+
board_id: str
|
|
56
|
+
name: str = ""
|
|
57
|
+
position: int = 0
|
|
58
|
+
policy: ColumnPolicy = field(default_factory=ColumnPolicy)
|
|
59
|
+
card_ids: list[str] = field(default_factory=list)
|
|
60
|
+
color: str | None = None
|
|
61
|
+
|
|
62
|
+
def can_accept_card(self, is_new_entry: bool) -> tuple[bool, str | None]:
|
|
63
|
+
if (
|
|
64
|
+
is_new_entry
|
|
65
|
+
and self.policy.wip_limit is not None
|
|
66
|
+
and len(self.card_ids) >= self.policy.wip_limit
|
|
67
|
+
):
|
|
68
|
+
return False, f"WIP limit of {self.policy.wip_limit} would be exceeded."
|
|
69
|
+
return True, None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Board:
|
|
74
|
+
"""Kanban board entity"""
|
|
75
|
+
|
|
76
|
+
id: str
|
|
77
|
+
version: int = 1
|
|
78
|
+
name: str = ""
|
|
79
|
+
description: str = ""
|
|
80
|
+
columns: dict[str, Column] = field(default_factory=dict)
|
|
81
|
+
cards: dict[str, Card] = field(default_factory=dict)
|
|
82
|
+
created_at: datetime | None = None
|
|
83
|
+
updated_at: datetime | None = None
|
|
84
|
+
owner_id: str | None = None
|
|
85
|
+
team_ids: set[str] = field(default_factory=set)
|
|
86
|
+
|
|
87
|
+
def get_columns_ordered(self) -> list[Column]:
|
|
88
|
+
return sorted(self.columns.values(), key=lambda c: c.position)
|
|
89
|
+
|
|
90
|
+
def column_for_card(self, card_id: str) -> Column:
|
|
91
|
+
for column in self.columns.values():
|
|
92
|
+
if card_id in column.card_ids:
|
|
93
|
+
return column
|
|
94
|
+
raise ValidationError(f"Card '{card_id}' is not assigned to a column")
|
|
95
|
+
|
|
96
|
+
def get_cards_in_column(self, column_id: str) -> list[Card]:
|
|
97
|
+
column = self.columns.get(column_id)
|
|
98
|
+
if not column:
|
|
99
|
+
return []
|
|
100
|
+
return [
|
|
101
|
+
self.cards[card_id] for card_id in column.card_ids if card_id in self.cards
|
|
102
|
+
]
|
kanban/permissions.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum, auto
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Permission(Enum):
|
|
9
|
+
VIEW_BOARD = auto()
|
|
10
|
+
EDIT_BOARD = auto()
|
|
11
|
+
DELETE_BOARD = auto()
|
|
12
|
+
CREATE_CARD = auto()
|
|
13
|
+
EDIT_CARD = auto()
|
|
14
|
+
MOVE_CARD = auto()
|
|
15
|
+
DELETE_CARD = auto()
|
|
16
|
+
CREATE_COLUMN = auto()
|
|
17
|
+
EDIT_COLUMN = auto()
|
|
18
|
+
DELETE_COLUMN = auto()
|
|
19
|
+
MANAGE_USERS = auto()
|
|
20
|
+
MANAGE_AUTOMATION = auto()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class PermissionContext:
|
|
25
|
+
board_id: str
|
|
26
|
+
owner_id: str
|
|
27
|
+
team_ids: frozenset[str]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PermissionPolicy(Protocol):
|
|
31
|
+
def has_permission(
|
|
32
|
+
self, user_id: str, context: PermissionContext, permission: Permission
|
|
33
|
+
) -> bool: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SimplePermissionPolicy:
|
|
37
|
+
DEFAULT_MEMBER_PERMISSIONS = {
|
|
38
|
+
Permission.VIEW_BOARD,
|
|
39
|
+
Permission.CREATE_CARD,
|
|
40
|
+
Permission.EDIT_CARD,
|
|
41
|
+
Permission.MOVE_CARD,
|
|
42
|
+
Permission.DELETE_CARD,
|
|
43
|
+
Permission.CREATE_COLUMN,
|
|
44
|
+
Permission.EDIT_COLUMN,
|
|
45
|
+
Permission.DELETE_COLUMN,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
def __init__(self, member_permissions: set[Permission] | None = None):
|
|
49
|
+
self.member_permissions = (
|
|
50
|
+
set(member_permissions)
|
|
51
|
+
if member_permissions is not None
|
|
52
|
+
else set(self.DEFAULT_MEMBER_PERMISSIONS)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def has_permission(
|
|
56
|
+
self, user_id: str, context: PermissionContext, permission: Permission
|
|
57
|
+
) -> bool:
|
|
58
|
+
if not user_id:
|
|
59
|
+
return False
|
|
60
|
+
if context.owner_id == user_id:
|
|
61
|
+
return True
|
|
62
|
+
if user_id in context.team_ids:
|
|
63
|
+
return permission in self.member_permissions
|
|
64
|
+
return False
|
kanban/policies.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Public, read-only extension points for organization-specific workflow rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class CardPolicyContext:
|
|
12
|
+
"""A detached card snapshot supplied to an external workflow policy."""
|
|
13
|
+
|
|
14
|
+
board_id: str
|
|
15
|
+
user_id: str
|
|
16
|
+
card_id: str
|
|
17
|
+
column_id: str
|
|
18
|
+
title: str
|
|
19
|
+
description: str
|
|
20
|
+
assignee_ids: frozenset[str]
|
|
21
|
+
due_date: datetime | None
|
|
22
|
+
size: float | None
|
|
23
|
+
archived: bool
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class CardMovePolicyContext:
|
|
28
|
+
"""The proposed card transition, before the aggregate is mutated."""
|
|
29
|
+
|
|
30
|
+
card: CardPolicyContext
|
|
31
|
+
from_column_id: str
|
|
32
|
+
to_column_id: str
|
|
33
|
+
position: int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class WorkflowPolicy(Protocol):
|
|
37
|
+
"""Optional policy port for deployment-specific card lifecycle rules.
|
|
38
|
+
|
|
39
|
+
Implementations validate only. They must not persist data, publish events, or
|
|
40
|
+
mutate the supplied contexts. A rejected operation should raise
|
|
41
|
+
``kanban.ValidationError`` (or a subclass).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def validate_create(self, context: CardPolicyContext) -> None: ...
|
|
45
|
+
|
|
46
|
+
def validate_update(self, context: CardPolicyContext) -> None: ...
|
|
47
|
+
|
|
48
|
+
def validate_move(self, context: CardMovePolicyContext) -> None: ...
|
|
49
|
+
|
|
50
|
+
def validate_delete(self, context: CardPolicyContext) -> None: ...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AllowAllWorkflowPolicy:
|
|
54
|
+
"""Default policy; structural domain invariants still apply."""
|
|
55
|
+
|
|
56
|
+
def validate_create(self, context: CardPolicyContext) -> None:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
def validate_update(self, context: CardPolicyContext) -> None:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
def validate_move(self, context: CardMovePolicyContext) -> None:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
def validate_delete(self, context: CardPolicyContext) -> None:
|
|
66
|
+
pass
|