eventsourcing 9.2.22__py3-none-any.whl → 9.3.0a1__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.
Potentially problematic release.
This version of eventsourcing might be problematic. Click here for more details.
- eventsourcing/__init__.py +1 -1
- eventsourcing/application.py +106 -135
- eventsourcing/cipher.py +15 -12
- eventsourcing/dispatch.py +31 -91
- eventsourcing/domain.py +138 -143
- eventsourcing/examples/__init__.py +0 -0
- eventsourcing/examples/aggregate1/__init__.py +0 -0
- eventsourcing/examples/aggregate1/application.py +27 -0
- eventsourcing/examples/aggregate1/domainmodel.py +16 -0
- eventsourcing/examples/aggregate1/test_application.py +37 -0
- eventsourcing/examples/aggregate2/__init__.py +0 -0
- eventsourcing/examples/aggregate2/application.py +27 -0
- eventsourcing/examples/aggregate2/domainmodel.py +22 -0
- eventsourcing/examples/aggregate2/test_application.py +37 -0
- eventsourcing/examples/aggregate3/__init__.py +0 -0
- eventsourcing/examples/aggregate3/application.py +27 -0
- eventsourcing/examples/aggregate3/domainmodel.py +38 -0
- eventsourcing/examples/aggregate3/test_application.py +37 -0
- eventsourcing/examples/aggregate4/__init__.py +0 -0
- eventsourcing/examples/aggregate4/application.py +27 -0
- eventsourcing/examples/aggregate4/domainmodel.py +128 -0
- eventsourcing/examples/aggregate4/test_application.py +38 -0
- eventsourcing/examples/aggregate5/__init__.py +0 -0
- eventsourcing/examples/aggregate5/application.py +27 -0
- eventsourcing/examples/aggregate5/domainmodel.py +131 -0
- eventsourcing/examples/aggregate5/test_application.py +38 -0
- eventsourcing/examples/aggregate6/__init__.py +0 -0
- eventsourcing/examples/aggregate6/application.py +30 -0
- eventsourcing/examples/aggregate6/domainmodel.py +123 -0
- eventsourcing/examples/aggregate6/test_application.py +38 -0
- eventsourcing/examples/aggregate6a/__init__.py +0 -0
- eventsourcing/examples/aggregate6a/application.py +40 -0
- eventsourcing/examples/aggregate6a/domainmodel.py +149 -0
- eventsourcing/examples/aggregate6a/test_application.py +45 -0
- eventsourcing/examples/aggregate7/__init__.py +0 -0
- eventsourcing/examples/aggregate7/application.py +48 -0
- eventsourcing/examples/aggregate7/domainmodel.py +144 -0
- eventsourcing/examples/aggregate7/persistence.py +57 -0
- eventsourcing/examples/aggregate7/test_application.py +38 -0
- eventsourcing/examples/aggregate7/test_compression_and_encryption.py +45 -0
- eventsourcing/examples/aggregate7/test_snapshotting_intervals.py +67 -0
- eventsourcing/examples/aggregate7a/__init__.py +0 -0
- eventsourcing/examples/aggregate7a/application.py +56 -0
- eventsourcing/examples/aggregate7a/domainmodel.py +170 -0
- eventsourcing/examples/aggregate7a/test_application.py +46 -0
- eventsourcing/examples/aggregate7a/test_compression_and_encryption.py +45 -0
- eventsourcing/examples/aggregate8/__init__.py +0 -0
- eventsourcing/examples/aggregate8/application.py +47 -0
- eventsourcing/examples/aggregate8/domainmodel.py +65 -0
- eventsourcing/examples/aggregate8/persistence.py +57 -0
- eventsourcing/examples/aggregate8/test_application.py +37 -0
- eventsourcing/examples/aggregate8/test_compression_and_encryption.py +44 -0
- eventsourcing/examples/aggregate8/test_snapshotting_intervals.py +38 -0
- eventsourcing/examples/bankaccounts/__init__.py +0 -0
- eventsourcing/examples/bankaccounts/application.py +70 -0
- eventsourcing/examples/bankaccounts/domainmodel.py +56 -0
- eventsourcing/examples/bankaccounts/test.py +173 -0
- eventsourcing/examples/cargoshipping/__init__.py +0 -0
- eventsourcing/examples/cargoshipping/application.py +126 -0
- eventsourcing/examples/cargoshipping/domainmodel.py +330 -0
- eventsourcing/examples/cargoshipping/interface.py +143 -0
- eventsourcing/examples/cargoshipping/test.py +231 -0
- eventsourcing/examples/contentmanagement/__init__.py +0 -0
- eventsourcing/examples/contentmanagement/application.py +118 -0
- eventsourcing/examples/contentmanagement/domainmodel.py +69 -0
- eventsourcing/examples/contentmanagement/test.py +180 -0
- eventsourcing/examples/contentmanagement/utils.py +26 -0
- eventsourcing/examples/contentmanagementsystem/__init__.py +0 -0
- eventsourcing/examples/contentmanagementsystem/application.py +54 -0
- eventsourcing/examples/contentmanagementsystem/postgres.py +17 -0
- eventsourcing/examples/contentmanagementsystem/sqlite.py +17 -0
- eventsourcing/examples/contentmanagementsystem/system.py +14 -0
- eventsourcing/examples/contentmanagementsystem/test_system.py +174 -0
- eventsourcing/examples/searchablecontent/__init__.py +0 -0
- eventsourcing/examples/searchablecontent/application.py +45 -0
- eventsourcing/examples/searchablecontent/persistence.py +23 -0
- eventsourcing/examples/searchablecontent/postgres.py +118 -0
- eventsourcing/examples/searchablecontent/sqlite.py +136 -0
- eventsourcing/examples/searchablecontent/test_application.py +111 -0
- eventsourcing/examples/searchablecontent/test_recorder.py +69 -0
- eventsourcing/examples/searchabletimestamps/__init__.py +0 -0
- eventsourcing/examples/searchabletimestamps/application.py +32 -0
- eventsourcing/examples/searchabletimestamps/persistence.py +20 -0
- eventsourcing/examples/searchabletimestamps/postgres.py +110 -0
- eventsourcing/examples/searchabletimestamps/sqlite.py +99 -0
- eventsourcing/examples/searchabletimestamps/test_searchabletimestamps.py +91 -0
- eventsourcing/examples/test_invoice.py +176 -0
- eventsourcing/examples/test_parking_lot.py +206 -0
- eventsourcing/interface.py +2 -2
- eventsourcing/persistence.py +85 -81
- eventsourcing/popo.py +30 -31
- eventsourcing/postgres.py +361 -578
- eventsourcing/sqlite.py +91 -99
- eventsourcing/system.py +42 -57
- eventsourcing/tests/application.py +20 -32
- eventsourcing/tests/application_tests/__init__.py +0 -0
- eventsourcing/tests/application_tests/test_application_with_automatic_snapshotting.py +55 -0
- eventsourcing/tests/application_tests/test_application_with_popo.py +22 -0
- eventsourcing/tests/application_tests/test_application_with_postgres.py +75 -0
- eventsourcing/tests/application_tests/test_application_with_sqlite.py +72 -0
- eventsourcing/tests/application_tests/test_cache.py +134 -0
- eventsourcing/tests/application_tests/test_event_sourced_log.py +162 -0
- eventsourcing/tests/application_tests/test_notificationlog.py +232 -0
- eventsourcing/tests/application_tests/test_notificationlogreader.py +126 -0
- eventsourcing/tests/application_tests/test_processapplication.py +110 -0
- eventsourcing/tests/application_tests/test_processingpolicy.py +109 -0
- eventsourcing/tests/application_tests/test_repository.py +504 -0
- eventsourcing/tests/application_tests/test_snapshotting.py +68 -0
- eventsourcing/tests/application_tests/test_upcasting.py +459 -0
- eventsourcing/tests/docs_tests/__init__.py +0 -0
- eventsourcing/tests/docs_tests/test_docs.py +293 -0
- eventsourcing/tests/domain.py +1 -1
- eventsourcing/tests/domain_tests/__init__.py +0 -0
- eventsourcing/tests/domain_tests/test_aggregate.py +1159 -0
- eventsourcing/tests/domain_tests/test_aggregate_decorators.py +1604 -0
- eventsourcing/tests/domain_tests/test_domainevent.py +80 -0
- eventsourcing/tests/interface_tests/__init__.py +0 -0
- eventsourcing/tests/interface_tests/test_remotenotificationlog.py +258 -0
- eventsourcing/tests/persistence.py +49 -50
- eventsourcing/tests/persistence_tests/__init__.py +0 -0
- eventsourcing/tests/persistence_tests/test_aes.py +93 -0
- eventsourcing/tests/persistence_tests/test_connection_pool.py +722 -0
- eventsourcing/tests/persistence_tests/test_eventstore.py +72 -0
- eventsourcing/tests/persistence_tests/test_infrastructure_factory.py +21 -0
- eventsourcing/tests/persistence_tests/test_mapper.py +113 -0
- eventsourcing/tests/persistence_tests/test_noninterleaving_notification_ids.py +69 -0
- eventsourcing/tests/persistence_tests/test_popo.py +124 -0
- eventsourcing/tests/persistence_tests/test_postgres.py +1121 -0
- eventsourcing/tests/persistence_tests/test_sqlite.py +348 -0
- eventsourcing/tests/persistence_tests/test_transcoder.py +44 -0
- eventsourcing/tests/postgres_utils.py +7 -7
- eventsourcing/tests/system_tests/__init__.py +0 -0
- eventsourcing/tests/system_tests/test_runner.py +935 -0
- eventsourcing/tests/system_tests/test_system.py +287 -0
- eventsourcing/tests/utils_tests/__init__.py +0 -0
- eventsourcing/tests/utils_tests/test_utils.py +226 -0
- eventsourcing/utils.py +47 -50
- {eventsourcing-9.2.22.dist-info → eventsourcing-9.3.0a1.dist-info}/METADATA +28 -80
- eventsourcing-9.3.0a1.dist-info/RECORD +144 -0
- {eventsourcing-9.2.22.dist-info → eventsourcing-9.3.0a1.dist-info}/WHEEL +1 -2
- eventsourcing-9.2.22.dist-info/AUTHORS +0 -10
- eventsourcing-9.2.22.dist-info/RECORD +0 -25
- eventsourcing-9.2.22.dist-info/top_level.txt +0 -1
- {eventsourcing-9.2.22.dist-info → eventsourcing-9.3.0a1.dist-info}/LICENSE +0 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Dict, List, Optional, Tuple, Union, cast
|
|
6
|
+
from uuid import UUID, uuid4
|
|
7
|
+
|
|
8
|
+
from eventsourcing.dispatch import singledispatchmethod
|
|
9
|
+
from eventsourcing.domain import Aggregate
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Location(Enum):
|
|
13
|
+
"""
|
|
14
|
+
Locations in the world.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
HAMBURG = "HAMBURG"
|
|
18
|
+
HONGKONG = "HONGKONG"
|
|
19
|
+
NEWYORK = "NEWYORK"
|
|
20
|
+
STOCKHOLM = "STOCKHOLM"
|
|
21
|
+
TOKYO = "TOKYO"
|
|
22
|
+
|
|
23
|
+
NLRTM = "NLRTM"
|
|
24
|
+
USDAL = "USDAL"
|
|
25
|
+
AUMEL = "AUMEL"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Leg:
|
|
29
|
+
"""
|
|
30
|
+
Leg of an itinerary.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
origin: str,
|
|
36
|
+
destination: str,
|
|
37
|
+
voyage_number: str,
|
|
38
|
+
):
|
|
39
|
+
self.origin: str = origin
|
|
40
|
+
self.destination: str = destination
|
|
41
|
+
self.voyage_number: str = voyage_number
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Itinerary:
|
|
45
|
+
"""
|
|
46
|
+
An itinerary along which cargo is shipped.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
origin: str,
|
|
52
|
+
destination: str,
|
|
53
|
+
legs: Tuple[Leg, ...],
|
|
54
|
+
):
|
|
55
|
+
self.origin = origin
|
|
56
|
+
self.destination = destination
|
|
57
|
+
self.legs = legs
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class HandlingActivity(Enum):
|
|
61
|
+
RECEIVE = "RECEIVE"
|
|
62
|
+
LOAD = "LOAD"
|
|
63
|
+
UNLOAD = "UNLOAD"
|
|
64
|
+
CLAIM = "CLAIM"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Custom static types.
|
|
68
|
+
LegDetails = Dict[str, str]
|
|
69
|
+
|
|
70
|
+
ItineraryDetails = Dict[str, Union[str, List[LegDetails]]]
|
|
71
|
+
|
|
72
|
+
NextExpectedActivity = Optional[Tuple[HandlingActivity, Location, str]]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Some routes from one location to another.
|
|
76
|
+
REGISTERED_ROUTES = {
|
|
77
|
+
("HONGKONG", "STOCKHOLM"): [
|
|
78
|
+
Itinerary(
|
|
79
|
+
origin="HONGKONG",
|
|
80
|
+
destination="STOCKHOLM",
|
|
81
|
+
legs=(
|
|
82
|
+
Leg(
|
|
83
|
+
origin="HONGKONG",
|
|
84
|
+
destination="NEWYORK",
|
|
85
|
+
voyage_number="V1",
|
|
86
|
+
),
|
|
87
|
+
Leg(
|
|
88
|
+
origin="NEWYORK",
|
|
89
|
+
destination="STOCKHOLM",
|
|
90
|
+
voyage_number="V2",
|
|
91
|
+
),
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
],
|
|
95
|
+
("TOKYO", "STOCKHOLM"): [
|
|
96
|
+
Itinerary(
|
|
97
|
+
origin="TOKYO",
|
|
98
|
+
destination="STOCKHOLM",
|
|
99
|
+
legs=(
|
|
100
|
+
Leg(
|
|
101
|
+
origin="TOKYO",
|
|
102
|
+
destination="HAMBURG",
|
|
103
|
+
voyage_number="V3",
|
|
104
|
+
),
|
|
105
|
+
Leg(
|
|
106
|
+
origin="HAMBURG",
|
|
107
|
+
destination="STOCKHOLM",
|
|
108
|
+
voyage_number="V4",
|
|
109
|
+
),
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
],
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Cargo(Aggregate):
|
|
117
|
+
"""
|
|
118
|
+
The Cargo aggregate is an event-sourced domain model aggregate that
|
|
119
|
+
specifies the routing from origin to destination, and can track what
|
|
120
|
+
happens to the cargo after it has been booked.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
origin: Location,
|
|
126
|
+
destination: Location,
|
|
127
|
+
arrival_deadline: datetime,
|
|
128
|
+
):
|
|
129
|
+
self._origin: Location = origin
|
|
130
|
+
self._destination: Location = destination
|
|
131
|
+
self._arrival_deadline: datetime = arrival_deadline
|
|
132
|
+
self._transport_status: str = "NOT_RECEIVED"
|
|
133
|
+
self._routing_status: str = "NOT_ROUTED"
|
|
134
|
+
self._is_misdirected: bool = False
|
|
135
|
+
self._estimated_time_of_arrival: datetime | None = None
|
|
136
|
+
self._next_expected_activity: NextExpectedActivity = None
|
|
137
|
+
self._route: Itinerary | None = None
|
|
138
|
+
self._last_known_location: Location | None = None
|
|
139
|
+
self._current_voyage_number: str | None = None
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def origin(self) -> Location:
|
|
143
|
+
return self._origin
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def destination(self) -> Location:
|
|
147
|
+
return self._destination
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def arrival_deadline(self) -> datetime:
|
|
151
|
+
return self._arrival_deadline
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def transport_status(self) -> str:
|
|
155
|
+
return self._transport_status
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def routing_status(self) -> str:
|
|
159
|
+
return self._routing_status
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def is_misdirected(self) -> bool:
|
|
163
|
+
return self._is_misdirected
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def estimated_time_of_arrival(
|
|
167
|
+
self,
|
|
168
|
+
) -> datetime | None:
|
|
169
|
+
return self._estimated_time_of_arrival
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def next_expected_activity(self) -> NextExpectedActivity:
|
|
173
|
+
return self._next_expected_activity
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def route(self) -> Itinerary | None:
|
|
177
|
+
return self._route
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def last_known_location(self) -> Location | None:
|
|
181
|
+
return self._last_known_location
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def current_voyage_number(self) -> str | None:
|
|
185
|
+
return self._current_voyage_number
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def new_booking(
|
|
189
|
+
cls,
|
|
190
|
+
origin: Location,
|
|
191
|
+
destination: Location,
|
|
192
|
+
arrival_deadline: datetime,
|
|
193
|
+
) -> Cargo:
|
|
194
|
+
return cls._create(
|
|
195
|
+
event_class=cls.BookingStarted,
|
|
196
|
+
id=uuid4(),
|
|
197
|
+
origin=origin,
|
|
198
|
+
destination=destination,
|
|
199
|
+
arrival_deadline=arrival_deadline,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
class BookingStarted(Aggregate.Created):
|
|
203
|
+
origin: Location
|
|
204
|
+
destination: Location
|
|
205
|
+
arrival_deadline: datetime
|
|
206
|
+
|
|
207
|
+
class Event(Aggregate.Event):
|
|
208
|
+
def apply(self, aggregate: Aggregate) -> None:
|
|
209
|
+
cast(Cargo, aggregate).when(self)
|
|
210
|
+
|
|
211
|
+
@singledispatchmethod
|
|
212
|
+
def when(self, event: Event) -> None:
|
|
213
|
+
"""
|
|
214
|
+
Default method to apply an aggregate event to the aggregate object.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
def change_destination(self, destination: Location) -> None:
|
|
218
|
+
self.trigger_event(
|
|
219
|
+
self.DestinationChanged,
|
|
220
|
+
destination=destination,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
class DestinationChanged(Event):
|
|
224
|
+
destination: Location
|
|
225
|
+
|
|
226
|
+
@when.register
|
|
227
|
+
def _(self, event: Cargo.DestinationChanged) -> None:
|
|
228
|
+
self._destination = event.destination
|
|
229
|
+
|
|
230
|
+
def assign_route(self, itinerary: Itinerary) -> None:
|
|
231
|
+
self.trigger_event(self.RouteAssigned, route=itinerary)
|
|
232
|
+
|
|
233
|
+
class RouteAssigned(Event):
|
|
234
|
+
route: Itinerary
|
|
235
|
+
|
|
236
|
+
@when.register
|
|
237
|
+
def _(self, event: Cargo.RouteAssigned) -> None:
|
|
238
|
+
self._route = event.route
|
|
239
|
+
self._routing_status = "ROUTED"
|
|
240
|
+
self._estimated_time_of_arrival = Cargo.Event.create_timestamp() + timedelta(
|
|
241
|
+
weeks=1
|
|
242
|
+
)
|
|
243
|
+
self._next_expected_activity = (HandlingActivity.RECEIVE, self.origin, "")
|
|
244
|
+
self._is_misdirected = False
|
|
245
|
+
|
|
246
|
+
def register_handling_event(
|
|
247
|
+
self,
|
|
248
|
+
tracking_id: UUID,
|
|
249
|
+
voyage_number: str | None,
|
|
250
|
+
location: Location,
|
|
251
|
+
handling_activity: HandlingActivity,
|
|
252
|
+
) -> None:
|
|
253
|
+
self.trigger_event(
|
|
254
|
+
self.HandlingEventRegistered,
|
|
255
|
+
tracking_id=tracking_id,
|
|
256
|
+
voyage_number=voyage_number,
|
|
257
|
+
location=location,
|
|
258
|
+
handling_activity=handling_activity,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
class HandlingEventRegistered(Event):
|
|
262
|
+
tracking_id: UUID
|
|
263
|
+
voyage_number: str
|
|
264
|
+
location: Location
|
|
265
|
+
handling_activity: str
|
|
266
|
+
|
|
267
|
+
@when.register
|
|
268
|
+
def _(self, event: Cargo.HandlingEventRegistered) -> None:
|
|
269
|
+
assert self.route is not None
|
|
270
|
+
if event.handling_activity == HandlingActivity.RECEIVE:
|
|
271
|
+
self._transport_status = "IN_PORT"
|
|
272
|
+
self._last_known_location = event.location
|
|
273
|
+
self._next_expected_activity = (
|
|
274
|
+
HandlingActivity.LOAD,
|
|
275
|
+
event.location,
|
|
276
|
+
self.route.legs[0].voyage_number,
|
|
277
|
+
)
|
|
278
|
+
elif event.handling_activity == HandlingActivity.LOAD:
|
|
279
|
+
self._transport_status = "ONBOARD_CARRIER"
|
|
280
|
+
self._current_voyage_number = event.voyage_number
|
|
281
|
+
for leg in self.route.legs:
|
|
282
|
+
if (
|
|
283
|
+
leg.origin == event.location.value
|
|
284
|
+
and leg.voyage_number == event.voyage_number
|
|
285
|
+
):
|
|
286
|
+
self._next_expected_activity = (
|
|
287
|
+
HandlingActivity.UNLOAD,
|
|
288
|
+
Location[leg.destination],
|
|
289
|
+
event.voyage_number,
|
|
290
|
+
)
|
|
291
|
+
break
|
|
292
|
+
else:
|
|
293
|
+
msg = "Can't find leg with origin={} and voyage_number={}".format(
|
|
294
|
+
event.location,
|
|
295
|
+
event.voyage_number,
|
|
296
|
+
)
|
|
297
|
+
raise Exception(msg)
|
|
298
|
+
|
|
299
|
+
elif event.handling_activity == HandlingActivity.UNLOAD:
|
|
300
|
+
self._current_voyage_number = None
|
|
301
|
+
self._last_known_location = event.location
|
|
302
|
+
self._transport_status = "IN_PORT"
|
|
303
|
+
if event.location == self.destination:
|
|
304
|
+
self._next_expected_activity = (
|
|
305
|
+
HandlingActivity.CLAIM,
|
|
306
|
+
event.location,
|
|
307
|
+
"",
|
|
308
|
+
)
|
|
309
|
+
elif event.location.value in [leg.destination for leg in self.route.legs]:
|
|
310
|
+
for i, leg in enumerate(self.route.legs):
|
|
311
|
+
if leg.voyage_number == event.voyage_number:
|
|
312
|
+
next_leg: Leg = self.route.legs[i + 1]
|
|
313
|
+
assert Location[next_leg.origin] == event.location
|
|
314
|
+
self._next_expected_activity = (
|
|
315
|
+
HandlingActivity.LOAD,
|
|
316
|
+
event.location,
|
|
317
|
+
next_leg.voyage_number,
|
|
318
|
+
)
|
|
319
|
+
break
|
|
320
|
+
else:
|
|
321
|
+
self._is_misdirected = True
|
|
322
|
+
self._next_expected_activity = None
|
|
323
|
+
|
|
324
|
+
elif event.handling_activity == HandlingActivity.CLAIM:
|
|
325
|
+
self._next_expected_activity = None
|
|
326
|
+
self._transport_status = "CLAIMED"
|
|
327
|
+
|
|
328
|
+
else:
|
|
329
|
+
msg = f"Unsupported handling event: {event.handling_activity}"
|
|
330
|
+
raise Exception(msg)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from eventsourcing.examples.cargoshipping.domainmodel import (
|
|
8
|
+
HandlingActivity,
|
|
9
|
+
Itinerary,
|
|
10
|
+
ItineraryDetails,
|
|
11
|
+
LegDetails,
|
|
12
|
+
Location,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING: # pragma: nocover
|
|
16
|
+
from eventsourcing.examples.cargoshipping.application import BookingApplication
|
|
17
|
+
|
|
18
|
+
NextExpectedActivityDetails = Optional[Tuple[str, ...]]
|
|
19
|
+
CargoDetails = Dict[
|
|
20
|
+
str, Optional[Union[str, bool, datetime, NextExpectedActivityDetails]]
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BookingService:
|
|
25
|
+
"""
|
|
26
|
+
Presents an application interface that uses
|
|
27
|
+
simple types of object (str, bool, datetime).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, app: BookingApplication):
|
|
31
|
+
self.app = app
|
|
32
|
+
|
|
33
|
+
def book_new_cargo(
|
|
34
|
+
self,
|
|
35
|
+
origin: str,
|
|
36
|
+
destination: str,
|
|
37
|
+
arrival_deadline: datetime,
|
|
38
|
+
) -> str:
|
|
39
|
+
tracking_id = self.app.book_new_cargo(
|
|
40
|
+
Location[origin],
|
|
41
|
+
Location[destination],
|
|
42
|
+
arrival_deadline,
|
|
43
|
+
)
|
|
44
|
+
return str(tracking_id)
|
|
45
|
+
|
|
46
|
+
def get_cargo_details(self, tracking_id: str) -> CargoDetails:
|
|
47
|
+
cargo = self.app.get_cargo(UUID(tracking_id))
|
|
48
|
+
|
|
49
|
+
# Present 'next_expected_activity'.
|
|
50
|
+
next_expected_activity: NextExpectedActivityDetails
|
|
51
|
+
if cargo.next_expected_activity is None:
|
|
52
|
+
next_expected_activity = None
|
|
53
|
+
elif len(cargo.next_expected_activity) == 2:
|
|
54
|
+
next_expected_activity = (
|
|
55
|
+
cargo.next_expected_activity[0].value,
|
|
56
|
+
cargo.next_expected_activity[1].value,
|
|
57
|
+
)
|
|
58
|
+
elif len(cargo.next_expected_activity) == 3:
|
|
59
|
+
next_expected_activity = (
|
|
60
|
+
cargo.next_expected_activity[0].value,
|
|
61
|
+
cargo.next_expected_activity[1].value,
|
|
62
|
+
cargo.next_expected_activity[2],
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
msg = f"Invalid next expected activity: {cargo.next_expected_activity}"
|
|
66
|
+
raise Exception(msg)
|
|
67
|
+
|
|
68
|
+
# Present 'last_known_location'.
|
|
69
|
+
if cargo.last_known_location is None:
|
|
70
|
+
last_known_location = None
|
|
71
|
+
else:
|
|
72
|
+
last_known_location = cargo.last_known_location.value
|
|
73
|
+
|
|
74
|
+
# Present the cargo details.
|
|
75
|
+
return {
|
|
76
|
+
"id": str(cargo.id),
|
|
77
|
+
"origin": cargo.origin.value,
|
|
78
|
+
"destination": cargo.destination.value,
|
|
79
|
+
"arrival_deadline": cargo.arrival_deadline,
|
|
80
|
+
"transport_status": cargo.transport_status,
|
|
81
|
+
"routing_status": cargo.routing_status,
|
|
82
|
+
"is_misdirected": cargo.is_misdirected,
|
|
83
|
+
"estimated_time_of_arrival": cargo.estimated_time_of_arrival,
|
|
84
|
+
"next_expected_activity": next_expected_activity,
|
|
85
|
+
"last_known_location": last_known_location,
|
|
86
|
+
"current_voyage_number": cargo.current_voyage_number,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
def change_destination(self, tracking_id: str, destination: str) -> None:
|
|
90
|
+
self.app.change_destination(UUID(tracking_id), Location[destination])
|
|
91
|
+
|
|
92
|
+
def request_possible_routes_for_cargo(
|
|
93
|
+
self, tracking_id: str
|
|
94
|
+
) -> List[ItineraryDetails]:
|
|
95
|
+
routes = self.app.request_possible_routes_for_cargo(UUID(tracking_id))
|
|
96
|
+
return [self.dict_from_itinerary(route) for route in routes]
|
|
97
|
+
|
|
98
|
+
def dict_from_itinerary(self, itinerary: Itinerary) -> ItineraryDetails:
|
|
99
|
+
legs_details = []
|
|
100
|
+
for leg in itinerary.legs:
|
|
101
|
+
leg_details: LegDetails = {
|
|
102
|
+
"origin": leg.origin,
|
|
103
|
+
"destination": leg.destination,
|
|
104
|
+
"voyage_number": leg.voyage_number,
|
|
105
|
+
}
|
|
106
|
+
legs_details.append(leg_details)
|
|
107
|
+
route_details: ItineraryDetails = {
|
|
108
|
+
"origin": itinerary.origin,
|
|
109
|
+
"destination": itinerary.destination,
|
|
110
|
+
"legs": legs_details,
|
|
111
|
+
}
|
|
112
|
+
return route_details
|
|
113
|
+
|
|
114
|
+
def assign_route(
|
|
115
|
+
self,
|
|
116
|
+
tracking_id: str,
|
|
117
|
+
route_details: ItineraryDetails,
|
|
118
|
+
) -> None:
|
|
119
|
+
routes = self.app.request_possible_routes_for_cargo(UUID(tracking_id))
|
|
120
|
+
for route in routes:
|
|
121
|
+
if route_details == self.dict_from_itinerary(route):
|
|
122
|
+
self.app.assign_route(UUID(tracking_id), route)
|
|
123
|
+
|
|
124
|
+
def register_handling_event(
|
|
125
|
+
self,
|
|
126
|
+
tracking_id: str,
|
|
127
|
+
voyage_number: str | None,
|
|
128
|
+
location: str,
|
|
129
|
+
handling_activity: str,
|
|
130
|
+
) -> None:
|
|
131
|
+
self.app.register_handling_event(
|
|
132
|
+
UUID(tracking_id),
|
|
133
|
+
voyage_number,
|
|
134
|
+
Location[location],
|
|
135
|
+
HandlingActivity[handling_activity],
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# Stub function that picks an itinerary from a list of possible itineraries.
|
|
140
|
+
def select_preferred_itinerary(
|
|
141
|
+
itineraries: List[ItineraryDetails],
|
|
142
|
+
) -> ItineraryDetails:
|
|
143
|
+
return itineraries[0]
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
|
|
6
|
+
from eventsourcing.examples.cargoshipping.application import BookingApplication
|
|
7
|
+
from eventsourcing.examples.cargoshipping.domainmodel import Cargo
|
|
8
|
+
from eventsourcing.examples.cargoshipping.interface import (
|
|
9
|
+
BookingService,
|
|
10
|
+
select_preferred_itinerary,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestBookingService(unittest.TestCase):
|
|
15
|
+
def setUp(self) -> None:
|
|
16
|
+
self.service = BookingService(BookingApplication())
|
|
17
|
+
|
|
18
|
+
def test_admin_can_book_new_cargo(self) -> None:
|
|
19
|
+
arrival_deadline = Cargo.Event.create_timestamp() + timedelta(weeks=3)
|
|
20
|
+
|
|
21
|
+
cargo_id = self.service.book_new_cargo(
|
|
22
|
+
origin="NLRTM",
|
|
23
|
+
destination="USDAL",
|
|
24
|
+
arrival_deadline=arrival_deadline,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
cargo_details = self.service.get_cargo_details(cargo_id)
|
|
28
|
+
self.assertTrue(cargo_details["id"])
|
|
29
|
+
self.assertEqual(cargo_details["origin"], "NLRTM")
|
|
30
|
+
self.assertEqual(cargo_details["destination"], "USDAL")
|
|
31
|
+
|
|
32
|
+
self.service.change_destination(cargo_id, destination="AUMEL")
|
|
33
|
+
cargo_details = self.service.get_cargo_details(cargo_id)
|
|
34
|
+
self.assertEqual(cargo_details["destination"], "AUMEL")
|
|
35
|
+
self.assertEqual(
|
|
36
|
+
cargo_details["arrival_deadline"],
|
|
37
|
+
arrival_deadline,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def test_scenario_cargo_from_hongkong_to_stockholm(
|
|
41
|
+
self,
|
|
42
|
+
) -> None:
|
|
43
|
+
# Test setup: A cargo should be shipped from
|
|
44
|
+
# Hongkong to Stockholm, and it should arrive
|
|
45
|
+
# in no more than two weeks.
|
|
46
|
+
origin = "HONGKONG"
|
|
47
|
+
destination = "STOCKHOLM"
|
|
48
|
+
arrival_deadline = Cargo.Event.create_timestamp() + timedelta(weeks=2)
|
|
49
|
+
|
|
50
|
+
# Use case 1: booking.
|
|
51
|
+
|
|
52
|
+
# A new cargo is booked, and the unique tracking
|
|
53
|
+
# id is assigned to the cargo.
|
|
54
|
+
tracking_id = self.service.book_new_cargo(origin, destination, arrival_deadline)
|
|
55
|
+
|
|
56
|
+
# The tracking id can be used to lookup the cargo
|
|
57
|
+
# in the repository.
|
|
58
|
+
# Important: The cargo, and thus the domain model,
|
|
59
|
+
# is responsible for determining the status of the
|
|
60
|
+
# cargo, whether it is on the right track or not
|
|
61
|
+
# and so on. This is core domain logic. Tracking
|
|
62
|
+
# the cargo basically amounts to presenting
|
|
63
|
+
# information extracted from the cargo aggregate
|
|
64
|
+
# in a suitable way.
|
|
65
|
+
cargo_details = self.service.get_cargo_details(tracking_id)
|
|
66
|
+
self.assertEqual(
|
|
67
|
+
cargo_details["transport_status"],
|
|
68
|
+
"NOT_RECEIVED",
|
|
69
|
+
)
|
|
70
|
+
self.assertEqual(cargo_details["routing_status"], "NOT_ROUTED")
|
|
71
|
+
self.assertEqual(cargo_details["is_misdirected"], False)
|
|
72
|
+
self.assertEqual(
|
|
73
|
+
cargo_details["estimated_time_of_arrival"],
|
|
74
|
+
None,
|
|
75
|
+
)
|
|
76
|
+
self.assertEqual(cargo_details["next_expected_activity"], None)
|
|
77
|
+
|
|
78
|
+
# Use case 2: routing.
|
|
79
|
+
#
|
|
80
|
+
# A number of possible routes for this cargo is
|
|
81
|
+
# requested and may be presented to the customer
|
|
82
|
+
# in some way for him/her to choose from.
|
|
83
|
+
# Selection could be affected by things like price
|
|
84
|
+
# and time of delivery, but this test simply uses
|
|
85
|
+
# an arbitrary selection to mimic that process.
|
|
86
|
+
itineraries = self.service.request_possible_routes_for_cargo(tracking_id)
|
|
87
|
+
route_details = select_preferred_itinerary(itineraries)
|
|
88
|
+
|
|
89
|
+
# The cargo is then assigned to the selected
|
|
90
|
+
# route, described by an itinerary.
|
|
91
|
+
self.service.assign_route(tracking_id, route_details)
|
|
92
|
+
|
|
93
|
+
cargo_details = self.service.get_cargo_details(tracking_id)
|
|
94
|
+
self.assertEqual(
|
|
95
|
+
cargo_details["transport_status"],
|
|
96
|
+
"NOT_RECEIVED",
|
|
97
|
+
)
|
|
98
|
+
self.assertEqual(cargo_details["routing_status"], "ROUTED")
|
|
99
|
+
self.assertEqual(cargo_details["is_misdirected"], False)
|
|
100
|
+
self.assertTrue(cargo_details["estimated_time_of_arrival"])
|
|
101
|
+
self.assertEqual(
|
|
102
|
+
cargo_details["next_expected_activity"],
|
|
103
|
+
("RECEIVE", "HONGKONG", ""),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Use case 3: handling
|
|
107
|
+
|
|
108
|
+
# A handling event registration attempt will be
|
|
109
|
+
# formed from parsing the data coming in as a
|
|
110
|
+
# handling report either via the web service
|
|
111
|
+
# interface or as an uploaded CSV file. The
|
|
112
|
+
# handling event factory tries to create a
|
|
113
|
+
# HandlingEvent from the attempt, and if the
|
|
114
|
+
# factory decides that this is a plausible
|
|
115
|
+
# handling event, it is stored. If the attempt
|
|
116
|
+
# is invalid, for example if no cargo exists for
|
|
117
|
+
# the specified tracking id, the attempt is
|
|
118
|
+
# rejected.
|
|
119
|
+
#
|
|
120
|
+
# Handling begins: cargo is received in Hongkong.
|
|
121
|
+
self.service.register_handling_event(tracking_id, None, "HONGKONG", "RECEIVE")
|
|
122
|
+
cargo_details = self.service.get_cargo_details(tracking_id)
|
|
123
|
+
self.assertEqual(cargo_details["transport_status"], "IN_PORT")
|
|
124
|
+
self.assertEqual(
|
|
125
|
+
cargo_details["last_known_location"],
|
|
126
|
+
"HONGKONG",
|
|
127
|
+
)
|
|
128
|
+
self.assertEqual(
|
|
129
|
+
cargo_details["next_expected_activity"],
|
|
130
|
+
("LOAD", "HONGKONG", "V1"),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Load onto voyage V1.
|
|
134
|
+
self.service.register_handling_event(tracking_id, "V1", "HONGKONG", "LOAD")
|
|
135
|
+
cargo_details = self.service.get_cargo_details(tracking_id)
|
|
136
|
+
self.assertEqual(cargo_details["current_voyage_number"], "V1")
|
|
137
|
+
self.assertEqual(
|
|
138
|
+
cargo_details["last_known_location"],
|
|
139
|
+
"HONGKONG",
|
|
140
|
+
)
|
|
141
|
+
self.assertEqual(
|
|
142
|
+
cargo_details["transport_status"],
|
|
143
|
+
"ONBOARD_CARRIER",
|
|
144
|
+
)
|
|
145
|
+
self.assertEqual(
|
|
146
|
+
cargo_details["next_expected_activity"],
|
|
147
|
+
("UNLOAD", "NEWYORK", "V1"),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Incorrectly unload in Tokyo.
|
|
151
|
+
self.service.register_handling_event(tracking_id, "V1", "TOKYO", "UNLOAD")
|
|
152
|
+
cargo_details = self.service.get_cargo_details(tracking_id)
|
|
153
|
+
self.assertEqual(cargo_details["current_voyage_number"], None)
|
|
154
|
+
self.assertEqual(cargo_details["last_known_location"], "TOKYO")
|
|
155
|
+
self.assertEqual(cargo_details["transport_status"], "IN_PORT")
|
|
156
|
+
self.assertEqual(cargo_details["is_misdirected"], True)
|
|
157
|
+
self.assertEqual(cargo_details["next_expected_activity"], None)
|
|
158
|
+
|
|
159
|
+
# Reroute.
|
|
160
|
+
itineraries = self.service.request_possible_routes_for_cargo(tracking_id)
|
|
161
|
+
route_details = select_preferred_itinerary(itineraries)
|
|
162
|
+
self.service.assign_route(tracking_id, route_details)
|
|
163
|
+
|
|
164
|
+
# Load in Tokyo.
|
|
165
|
+
self.service.register_handling_event(tracking_id, "V3", "TOKYO", "LOAD")
|
|
166
|
+
cargo_details = self.service.get_cargo_details(tracking_id)
|
|
167
|
+
self.assertEqual(cargo_details["current_voyage_number"], "V3")
|
|
168
|
+
self.assertEqual(cargo_details["last_known_location"], "TOKYO")
|
|
169
|
+
self.assertEqual(
|
|
170
|
+
cargo_details["transport_status"],
|
|
171
|
+
"ONBOARD_CARRIER",
|
|
172
|
+
)
|
|
173
|
+
self.assertEqual(cargo_details["is_misdirected"], False)
|
|
174
|
+
self.assertEqual(
|
|
175
|
+
cargo_details["next_expected_activity"],
|
|
176
|
+
("UNLOAD", "HAMBURG", "V3"),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Unload in Hamburg.
|
|
180
|
+
self.service.register_handling_event(tracking_id, "V3", "HAMBURG", "UNLOAD")
|
|
181
|
+
cargo_details = self.service.get_cargo_details(tracking_id)
|
|
182
|
+
self.assertEqual(cargo_details["current_voyage_number"], None)
|
|
183
|
+
self.assertEqual(cargo_details["last_known_location"], "HAMBURG")
|
|
184
|
+
self.assertEqual(cargo_details["transport_status"], "IN_PORT")
|
|
185
|
+
self.assertEqual(cargo_details["is_misdirected"], False)
|
|
186
|
+
self.assertEqual(
|
|
187
|
+
cargo_details["next_expected_activity"],
|
|
188
|
+
("LOAD", "HAMBURG", "V4"),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Load in Hamburg
|
|
192
|
+
self.service.register_handling_event(tracking_id, "V4", "HAMBURG", "LOAD")
|
|
193
|
+
cargo_details = self.service.get_cargo_details(tracking_id)
|
|
194
|
+
self.assertEqual(cargo_details["current_voyage_number"], "V4")
|
|
195
|
+
self.assertEqual(cargo_details["last_known_location"], "HAMBURG")
|
|
196
|
+
self.assertEqual(
|
|
197
|
+
cargo_details["transport_status"],
|
|
198
|
+
"ONBOARD_CARRIER",
|
|
199
|
+
)
|
|
200
|
+
self.assertEqual(cargo_details["is_misdirected"], False)
|
|
201
|
+
self.assertEqual(
|
|
202
|
+
cargo_details["next_expected_activity"],
|
|
203
|
+
("UNLOAD", "STOCKHOLM", "V4"),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Unload in Stockholm
|
|
207
|
+
self.service.register_handling_event(tracking_id, "V4", "STOCKHOLM", "UNLOAD")
|
|
208
|
+
cargo_details = self.service.get_cargo_details(tracking_id)
|
|
209
|
+
self.assertEqual(cargo_details["current_voyage_number"], None)
|
|
210
|
+
self.assertEqual(
|
|
211
|
+
cargo_details["last_known_location"],
|
|
212
|
+
"STOCKHOLM",
|
|
213
|
+
)
|
|
214
|
+
self.assertEqual(cargo_details["transport_status"], "IN_PORT")
|
|
215
|
+
self.assertEqual(cargo_details["is_misdirected"], False)
|
|
216
|
+
self.assertEqual(
|
|
217
|
+
cargo_details["next_expected_activity"],
|
|
218
|
+
("CLAIM", "STOCKHOLM", ""),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Finally, cargo is claimed in Stockholm.
|
|
222
|
+
self.service.register_handling_event(tracking_id, None, "STOCKHOLM", "CLAIM")
|
|
223
|
+
cargo_details = self.service.get_cargo_details(tracking_id)
|
|
224
|
+
self.assertEqual(cargo_details["current_voyage_number"], None)
|
|
225
|
+
self.assertEqual(
|
|
226
|
+
cargo_details["last_known_location"],
|
|
227
|
+
"STOCKHOLM",
|
|
228
|
+
)
|
|
229
|
+
self.assertEqual(cargo_details["transport_status"], "CLAIMED")
|
|
230
|
+
self.assertEqual(cargo_details["is_misdirected"], False)
|
|
231
|
+
self.assertEqual(cargo_details["next_expected_activity"], None)
|
|
File without changes
|