├── .gitignore
├── README.md
├── docs
└── image
│ ├── bounded-context.png
│ └── erd.png
└── src
├── alembic.ini
├── display
├── __init__.py
├── application
│ ├── __init__.py
│ └── use_case
│ │ ├── __init__.py
│ │ └── query.py
├── domain
│ ├── __init__.py
│ └── entity
│ │ ├── __init__.py
│ │ └── room.py
├── infra
│ ├── __init__.py
│ ├── container.py
│ ├── external_api
│ │ └── __init__.py
│ └── repository.py
└── presentation
│ ├── __init__.py
│ ├── grpc
│ └── __init__.py
│ └── rest
│ ├── __init__.py
│ ├── api.py
│ ├── request.py
│ └── response.py
├── pytest.ini
├── reception
├── __init__.py
├── application
│ ├── __init__.py
│ └── use_case
│ │ ├── __init__.py
│ │ ├── command.py
│ │ └── query.py
├── domain
│ ├── __init__.py
│ ├── entity
│ │ ├── __init__.py
│ │ ├── reservation.py
│ │ └── room.py
│ ├── exception
│ │ ├── __init__.py
│ │ ├── check_in.py
│ │ ├── reservation.py
│ │ └── room.py
│ ├── service
│ │ ├── __init__.py
│ │ └── check_in.py
│ └── value_object
│ │ ├── __init__.py
│ │ ├── guest.py
│ │ └── reservation.py
├── infra
│ ├── __init__.py
│ ├── container.py
│ ├── external_api
│ │ └── __init__.py
│ └── repository.py
└── presentation
│ ├── __init__.py
│ ├── grpc
│ └── __init__.py
│ └── rest
│ ├── __init__.py
│ ├── api.py
│ ├── request.py
│ └── response.py
├── requirements.txt
├── shared_kernel
├── __init__.py
├── domain
│ ├── __init__.py
│ ├── entity.py
│ ├── exception.py
│ └── value_object.py
├── infra
│ ├── __init__.py
│ ├── container.py
│ ├── database
│ │ ├── __init__.py
│ │ ├── connection.py
│ │ ├── migrations
│ │ │ ├── README
│ │ │ ├── __init__.py
│ │ │ ├── env.py
│ │ │ ├── script.py.mako
│ │ │ └── versions
│ │ │ │ ├── 6b595c7689ad_init.py
│ │ │ │ └── __init__.py
│ │ ├── orm.py
│ │ └── repository.py
│ ├── fastapi
│ │ ├── __init__.py
│ │ ├── config.py
│ │ └── main.py
│ └── log
│ │ └── __init__.py
└── presentation
│ ├── __init__.py
│ └── response.py
└── tests
├── __init__.py
├── conftest.py
├── functional
├── __init__.py
├── test_display.py
└── test_reception.py
├── integration
├── __init__.py
└── database
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_display.py
│ └── test_reception.py
└── unit
└── __init__.py
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by https://www.toptal.com/developers/gitignore/api/python,pycharm+all,visualstudiocode,macos,windows
2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,pycharm+all,visualstudiocode,macos,windows
3 |
4 | ### macOS ###
5 | # General
6 | .DS_Store
7 | .AppleDouble
8 | .LSOverride
9 |
10 | # Icon must end with two \r
11 | Icon
12 |
13 |
14 | # Thumbnails
15 | ._*
16 |
17 | # Files that might appear in the root of a volume
18 | .DocumentRevisions-V100
19 | .fseventsd
20 | .Spotlight-V100
21 | .TemporaryItems
22 | .Trashes
23 | .VolumeIcon.icns
24 | .com.apple.timemachine.donotpresent
25 |
26 | # Directories potentially created on remote AFP share
27 | .AppleDB
28 | .AppleDesktop
29 | Network Trash Folder
30 | Temporary Items
31 | .apdisk
32 |
33 | ### macOS Patch ###
34 | # iCloud generated files
35 | *.icloud
36 |
37 | ### PyCharm+all ###
38 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
39 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
40 |
41 | # User-specific stuff
42 | .idea/**/workspace.xml
43 | .idea/**/tasks.xml
44 | .idea/**/usage.statistics.xml
45 | .idea/**/dictionaries
46 | .idea/**/shelf
47 |
48 | # AWS User-specific
49 | .idea/**/aws.xml
50 |
51 | # Generated files
52 | .idea/**/contentModel.xml
53 |
54 | # Sensitive or high-churn files
55 | .idea/**/dataSources/
56 | .idea/**/dataSources.ids
57 | .idea/**/dataSources.local.xml
58 | .idea/**/sqlDataSources.xml
59 | .idea/**/dynamic.xml
60 | .idea/**/uiDesigner.xml
61 | .idea/**/dbnavigator.xml
62 |
63 | # Gradle
64 | .idea/**/gradle.xml
65 | .idea/**/libraries
66 |
67 | # Gradle and Maven with auto-import
68 | # When using Gradle or Maven with auto-import, you should exclude module files,
69 | # since they will be recreated, and may cause churn. Uncomment if using
70 | # auto-import.
71 | # .idea/artifacts
72 | # .idea/compiler.xml
73 | # .idea/jarRepositories.xml
74 | # .idea/modules.xml
75 | # .idea/*.iml
76 | # .idea/modules
77 | # *.iml
78 | # *.ipr
79 |
80 | # CMake
81 | cmake-build-*/
82 |
83 | # Mongo Explorer plugin
84 | .idea/**/mongoSettings.xml
85 |
86 | # File-based project format
87 | *.iws
88 |
89 | # IntelliJ
90 | out/
91 |
92 | # mpeltonen/sbt-idea plugin
93 | .idea_modules/
94 |
95 | # JIRA plugin
96 | atlassian-ide-plugin.xml
97 |
98 | # Cursive Clojure plugin
99 | .idea/replstate.xml
100 |
101 | # SonarLint plugin
102 | .idea/sonarlint/
103 |
104 | # Crashlytics plugin (for Android Studio and IntelliJ)
105 | com_crashlytics_export_strings.xml
106 | crashlytics.properties
107 | crashlytics-build.properties
108 | fabric.properties
109 |
110 | # Editor-based Rest Client
111 | .idea/httpRequests
112 |
113 | # Android studio 3.1+ serialized cache file
114 | .idea/caches/build_file_checksums.ser
115 |
116 | ### PyCharm+all Patch ###
117 | # Ignore everything but code style settings and run configurations
118 | # that are supposed to be shared within teams.
119 |
120 | .idea/*
121 |
122 | !.idea/codeStyles
123 | !.idea/runConfigurations
124 |
125 | ### Python ###
126 | # Byte-compiled / optimized / DLL files
127 | __pycache__/
128 | *.py[cod]
129 | *$py.class
130 |
131 | # C extensions
132 | *.so
133 |
134 | # Distribution / packaging
135 | .Python
136 | build/
137 | develop-eggs/
138 | dist/
139 | downloads/
140 | eggs/
141 | .eggs/
142 | lib/
143 | lib64/
144 | parts/
145 | sdist/
146 | var/
147 | wheels/
148 | share/python-wheels/
149 | *.egg-info/
150 | .installed.cfg
151 | *.egg
152 | MANIFEST
153 |
154 | # PyInstaller
155 | # Usually these files are written by a python script from a template
156 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
157 | *.manifest
158 | *.spec
159 |
160 | # Installer logs
161 | pip-log.txt
162 | pip-delete-this-directory.txt
163 |
164 | # Unit test / coverage reports
165 | htmlcov/
166 | .tox/
167 | .nox/
168 | .coverage
169 | .coverage.*
170 | .cache
171 | nosetests.xml
172 | coverage.xml
173 | *.cover
174 | *.py,cover
175 | .hypothesis/
176 | .pytest_cache/
177 | cover/
178 |
179 | # Translations
180 | *.mo
181 | *.pot
182 |
183 | # Django stuff:
184 | *.log
185 | local_settings.py
186 | db.sqlite3
187 | db.sqlite3-journal
188 |
189 | # Flask stuff:
190 | instance/
191 | .webassets-cache
192 |
193 | # Scrapy stuff:
194 | .scrapy
195 |
196 | # Sphinx documentation
197 | docs/_build/
198 |
199 | # PyBuilder
200 | .pybuilder/
201 | target/
202 |
203 | # Jupyter Notebook
204 | .ipynb_checkpoints
205 |
206 | # IPython
207 | profile_default/
208 | ipython_config.py
209 |
210 | # pyenv
211 | # For a library or package, you might want to ignore these files since the code is
212 | # intended to run in multiple environments; otherwise, check them in:
213 | .python-version
214 |
215 | # pipenv
216 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
217 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
218 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
219 | # install all needed dependencies.
220 | #Pipfile.lock
221 |
222 | # poetry
223 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
224 | # This is especially recommended for binary packages to ensure reproducibility, and is more
225 | # commonly ignored for libraries.
226 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
227 | #poetry.lock
228 |
229 | # pdm
230 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
231 | #pdm.lock
232 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
233 | # in version control.
234 | # https://pdm.fming.dev/#use-with-ide
235 | .pdm.toml
236 |
237 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
238 | __pypackages__/
239 |
240 | # Celery stuff
241 | celerybeat-schedule
242 | celerybeat.pid
243 |
244 | # SageMath parsed files
245 | *.sage.py
246 |
247 | # Environments
248 | .env
249 | .venv
250 | env/
251 | venv/
252 | ENV/
253 | env.bak/
254 | venv.bak/
255 |
256 | # Spyder project settings
257 | .spyderproject
258 | .spyproject
259 |
260 | # Rope project settings
261 | .ropeproject
262 |
263 | # mkdocs documentation
264 | /site
265 |
266 | # mypy
267 | .mypy_cache/
268 | .dmypy.json
269 | dmypy.json
270 |
271 | # Pyre type checker
272 | .pyre/
273 |
274 | # pytype static type analyzer
275 | .pytype/
276 |
277 | # Cython debug symbols
278 | cython_debug/
279 |
280 | # PyCharm
281 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
282 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
283 | # and can be added to the global gitignore or merged into this file. For a more nuclear
284 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
285 | #.idea/
286 |
287 | ### VisualStudioCode ###
288 | .vscode/*
289 | !.vscode/settings.json
290 | !.vscode/tasks.json
291 | !.vscode/launch.json
292 | !.vscode/extensions.json
293 | !.vscode/*.code-snippets
294 |
295 | # Local History for Visual Studio Code
296 | .history/
297 |
298 | # Built Visual Studio Code Extensions
299 | *.vsix
300 |
301 | ### VisualStudioCode Patch ###
302 | # Ignore all local history of files
303 | .history
304 | .ionide
305 |
306 | ### Windows ###
307 | # Windows thumbnail cache files
308 | Thumbs.db
309 | Thumbs.db:encryptable
310 | ehthumbs.db
311 | ehthumbs_vista.db
312 |
313 | # Dump file
314 | *.stackdump
315 |
316 | # Folder config file
317 | [Dd]esktop.ini
318 |
319 | # Recycle Bin used on file shares
320 | $RECYCLE.BIN/
321 |
322 | # Windows Installer files
323 | *.cab
324 | *.msi
325 | *.msix
326 | *.msm
327 | *.msp
328 |
329 | # Windows shortcuts
330 | *.lnk
331 |
332 | # End of https://www.toptal.com/developers/gitignore/api/python,pycharm+all,visualstudiocode,macos,windows
333 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Python Domain-Driven-Design(DDD) Example
2 |
3 | ## Intro
4 | I've adopted the DDD pattern for my recent FastAPI project.
5 | DDD makes it easier to implement complex domain problems.
6 | Improved readability and easy code fix have significantly improved productivity.
7 | As a result, stable but flexible project management has become possible.
8 | I'm very satisfied with it, so I'd like to share this experience and knowledge.
9 |
10 | ### Why DDD?
11 | Using DDD makes it easy to maintain collaboration with domain experts, not only engineers.
12 | - It is possible to prevent the mental model and the actual software from being dualized.
13 | - Business logic is easy to manage.
14 | - Infrastructure change is flexible.
15 |
16 |
17 | ### Objective
18 | - Let's create a simple hotel reservation system and see how each component of DDD is implemented.
19 | - Don't go too deep into topics like event sourcing.
20 | - Considering the running curve, this project consists only of essential DDD components.
21 |
22 |
23 | ## Implementation
24 | ### ERD
25 | > NOTES: The diagram below represents only the database tables.
26 |
27 | 
28 |
29 | ### Bounded Context
30 |
31 | 
32 |
33 | - Display(Handling tasks related to the hotel room display)
34 | - List Rooms
35 | - Reception(Handling tasks related to the hotel room reservation)
36 | - Make a reservation
37 | - Change the reservation details
38 | - Cancel a reservation
39 | - Check-in & Check-out
40 |
41 | Reservation and reception can also be isolated, but let's say that reception handles it altogether for now.
42 |
43 | ### Project Structure
44 | ```tree
45 | src
46 | ├── reception
47 | │ ├── application
48 | │ │ └── use_case
49 | │ │ ├── query
50 | │ │ └── command
51 | │ ├── domain
52 | │ │ ├── entity
53 | │ │ ├── exception
54 | │ │ ├── service
55 | │ │ └── value_object
56 | │ ├── infra
57 | │ │ ├── repository
58 | │ │ └── external_apis
59 | │ └── presentation
60 | │ ├── grpc
61 | │ └── rest
62 | │ ├── request
63 | │ └── response
64 | ├── display
65 | │ ├── application
66 | │ ├── domain
67 | │ ├── infra
68 | │ └── presentation
69 | └── shared_kernel
70 | ├── domain
71 | └── infra
72 | ├── database
73 | ├── fastapi
74 | └── log
75 | ```
76 |
77 | ### DDD Components
78 | #### 1. Entity: Definition
79 | ```python
80 | from dataclasses import dataclass, field
81 |
82 |
83 | class Entity:
84 | id: int = field(init=False)
85 |
86 | def __eq__(self, other: Any) -> bool:
87 | if isinstance(other, type(self)):
88 | return self.id == other.id
89 | return False
90 |
91 | def __hash__(self):
92 | return hash(self.id)
93 |
94 |
95 | class AggregateRoot(Entity):
96 | pass
97 |
98 |
99 | @dataclass(eq=False, slots=True)
100 | class Reservation(AggregateRoot):
101 | room: Room
102 | reservation_number: ReservationNumber
103 | reservation_status: ReservationStatus
104 | date_in: datetime
105 | date_out: datetime
106 | guest: Guest
107 | ```
108 |
109 | - Entity mix-in
110 | The entity is an object that have a distinct identity.
111 | I will implement `__eq__()` and `__hash__()`, to use it as a mix-in for dataclass.
112 |
113 | - AggregateRoot mix-in
114 | A DDD aggregate is a cluster of domain objects that can be treated as a single unit.
115 | An aggregate root is an entry point of an aggregate.
116 | Any references from outside the aggregate should only go to the aggregate root.
117 | The root can thus ensure the integrity of the aggregate as a whole.
118 | I will define an empty class called `AggregateRoot` and explicitly mark it.
119 |
120 | - Entity Implementation
121 | To use `__eq__()` from `Entity` mix-in, add `eq=False`.
122 | From Python 3.10, `slots=True` makes dataclass more memory-efficient.
123 |
124 | - Value Object
125 | With sqlalchemy, you can use value objects within entity when reading & saving data from a repository.
126 | I will introduce the details later.
127 |
128 | #### 2. Entity: Life Cycle
129 |
130 | ```python
131 | @dataclass(eq=False, slots=True)
132 | class Reservation(AggregateRoot):
133 | # ...
134 |
135 | @classmethod
136 | def make(
137 | cls, room: Room, date_in: datetime, date_out: datetime, guest: Guest
138 | ) -> Reservation:
139 | room.reserve()
140 | return cls(
141 | room=room,
142 | date_in=date_in,
143 | date_out=date_out,
144 | guest=guest,
145 | reservation_number=ReservationNumber.generate(),
146 | reservation_status=ReservationStatus.IN_PROGRESS,
147 | )
148 |
149 | def cancel(self):
150 | if not self.reservation_status.in_progress:
151 | raise ReservationStatusException
152 |
153 | self.reservation_status = ReservationStatus.CANCELLED
154 |
155 | def check_in(self):
156 | # ...
157 |
158 | def check_out(self):
159 | # ...
160 |
161 | def change_guest(self, guest: Guest):
162 | # ...
163 | ```
164 |
165 | By implementing the method according to the entity's life cycle, you can expect how it evolves when reading it.
166 |
167 | - Creation
168 | Declare a `class method` and use it when creating an entity.
169 |
170 | - Changes
171 | Declare an `instance method` and use it when changing an entity.
172 |
173 | #### 3. Entity: Table Mapping
174 | > NOTE: This is the most beautiful part of implementing DDD with sqlalchemy.
175 | - [ddd_hotel/database/orm.py](src/shared_kernel/infra/database/orm.py)
176 |
177 | ```python
178 | from sqlalchemy import MetaData, Table, Column, Integer, String, Text, ForeignKey, DateTime
179 | from sqlalchemy.orm import registry
180 |
181 | metadata = MetaData()
182 | mapper_registry = registry()
183 |
184 | room_table = Table(
185 | "hotel_room",
186 | metadata,
187 | Column("id", Integer, primary_key=True, autoincrement=True),
188 | Column("number", String(20), nullable=False),
189 | Column("status", String(20), nullable=False),
190 | Column("image_url", String(200), nullable=False),
191 | Column("description", Text, nullable=True),
192 | UniqueConstraint("number", name="uix_hotel_room_number"),
193 | )
194 |
195 | reservation_table = Table(
196 | "room_reservation",
197 | metadata,
198 | Column("id", Integer, primary_key=True, autoincrement=True),
199 | Column("room_id", Integer, ForeignKey("hotel_room.id"), nullable=False),
200 | Column("number", String(20), nullable=False),
201 | Column("status", String(20), nullable=False),
202 | Column("date_in", DateTime(timezone=True)),
203 | Column("date_out", DateTime(timezone=True)),
204 | Column("guest_mobile", String(20), nullable=False),
205 | Column("guest_name", String(50), nullable=True),
206 | )
207 |
208 |
209 | def init_orm_mappers():
210 | from reception.domain.entity.room import Room as ReceptionRoomEntity
211 | from reception.domain.entity.reservation import Reservation as ReceptionReservationEntity
212 |
213 | mapper_registry.map_imperatively(
214 | ReceptionRoomEntity,
215 | room_table,
216 | properties={
217 | "room_status": composite(RoomStatus.from_value, room_table.c.status),
218 | }
219 | )
220 | mapper_registry.map_imperatively(
221 | ReceptionReservationEntity,
222 | reservation_table,
223 | properties={
224 | "room": relationship(
225 | Room, backref="reservations", order_by=reservation_table.c.id.desc, lazy="joined"
226 | ),
227 | "reservation_number": composite(ReservationNumber.from_value, reservation_table.c.number),
228 | "reservation_status": composite(ReservationStatus.from_value, reservation_table.c.status),
229 | "guest": composite(Guest, reservation_table.c.guest_mobile, reservation_table.c.guest_name),
230 | }
231 | )
232 |
233 | from display.domain.entity.room import Room as DisplayRoomEntity
234 |
235 | mapper_registry.map_imperatively(
236 | DisplayRoomEntity,
237 | room_table,
238 | properties={
239 | "room_status": composite(RoomStatus.from_value, room_table.c.status),
240 | }
241 | )
242 | ```
243 |
244 | ```python
245 | # call this after app running
246 | init_orm_mappers()
247 | ```
248 |
249 | Because entities do not need to know the implementation of the database table, let's use sqlalchemy's [imperative mapping](https://docs.sqlalchemy.org/en/14/orm/mapping_styles.html#imperative-mapping) to separate entity definitions and table definitions.
250 |
251 | Because name conflicts can occur when mapping tables and entities, the `number` is replaced like `reservation_number`.
252 |
253 | If you want to keep using the `number` as it is, you can change the original `number` to `_number` first.
254 |
255 |
256 | ```python
257 | @dataclass(eq=False, slots=True)
258 | class Room(Entity):
259 | number: str
260 | room_status: RoomStatus
261 | ```
262 |
263 | Entities only need to use logically required data among the columns defined in the table.
264 | For example, in the `reservation` domain, you don't need to know the `image` of the `room`, so only `name`, `status` is defined in the `room`.
265 |
266 | #### 4. Value Object
267 | ```python
268 | from pydantic import constr
269 |
270 |
271 | mobile_type = constr(regex=r"\+[0-9]{2,3}-[0-9]{2}-[0-9]{4}-[0-9]{4}")
272 |
273 | @dataclass(slots=True)
274 | class Guest(ValueObject):
275 | mobile: mobile_type
276 | name: str | None = None
277 | ```
278 |
279 | A value object is an object that matter only as the combination of its attributes.
280 | Guest A's name and mobile should be treated as a single unit, so make it a value object.
281 |
282 | Using sqlalchemy's [composite column type](https://docs.sqlalchemy.org/en/14/orm/composites.html#composite-column-types), it allows you to implement value objects by changing columns to an object that fits your needs when you load data.
283 |
284 | Let's define the mix-in as follows and inherit it when implementing a value object.
285 |
286 | ```python
287 | class ValueObject:
288 | def __composite_values__(self):
289 | return self.value,
290 |
291 | @classmethod
292 | def from_value(cls, value: Any) -> ValueObjectType | None:
293 | if isinstance(cls, EnumMeta):
294 | for item in cls:
295 | if item.value == value:
296 | return item
297 | raise ValueObjectEnumError
298 |
299 | instance = cls(value=value)
300 | return instance
301 |
302 | ```
303 |
304 | If you define the `__composite_values_()` method, sqlalchemy separates the object and puts them in the columns when you save the data.
305 |
306 | > NOTE: The , in the return of `__composite_value__()` is not a typo.
307 |
308 | ```python
309 | class RoomStatus(ValueObject, str, Enum):
310 | AVAILABLE = "AVAILABLE"
311 | RESERVED = "RESERVED"
312 | OCCUPIED = "OCCUPIED"
313 |
314 |
315 | @dataclass(slots=True)
316 | class ReservationNumber(ValueObject):
317 | DATETIME_FORMAT: ClassVar[str] = "%y%m%d%H%M%S"
318 | RANDOM_STR_LENGTH: ClassVar[int] = 7
319 |
320 | value: str
321 |
322 | @classmethod
323 | def generate(cls) -> ReservationNumber:
324 | time_part: str = datetime.utcnow().strftime(cls.DATETIME_FORMAT)
325 | random_strings: str = ''.join(
326 | random.choice(string.ascii_uppercase + string.digits)
327 | for _ in range(cls.RANDOM_STR_LENGTH)
328 | )
329 | return cls(value=time_part + ":" + random_strings)
330 | ```
331 |
332 | `ReservationNumber` intentionally used the name `value` for a single attribute to leverage `__composite_values__()` in `ValueObject` class.
333 |
334 |
335 | ```python
336 | @dataclass(slots=True)
337 | class Guest(ValueObject):
338 | mobile: mobile_type
339 | name: str | None = None
340 |
341 | def __composite_values__(self):
342 | return self.mobile, self.name
343 | ```
344 |
345 | If a value object consists of more than one column, you must override the `__composite_values__()` as shown above.
346 |
347 | #### 5. Exception
348 | ```python
349 | class ReservationStatusException(BaseMsgException):
350 | message = "Invalid request for current reservation status."
351 |
352 |
353 | @dataclass(eq=False, slots=True)
354 | class Reservation(AggregateRoot):
355 | # ...
356 |
357 | def cancel(self):
358 | if not self.reservation_status.in_progress:
359 | raise ReservationStatusException
360 |
361 | self.reservation_status = ReservationStatus.CANCELLED
362 | ```
363 |
364 | By defining and using domain exceptions, the cohesion can be increased.
365 |
366 | #### Dependency Injection
367 | FastAPI's `Depends` makes it easy to implement **Dependency Injection** between layers.
368 | And you can achieve [Inversion of control](https://en.wikipedia.org/wiki/Inversion_of_control) with [Dependency Injector](https://python-dependency-injector.ets-labs.org/index.html).
369 |
370 | - [presentation/rest/reception.py](src/reception/presentation/rest/api.py)
371 |
372 | ```python
373 | @router.get("/reservations/{reservation_number}")
374 | @inject
375 | def get_reservation(
376 | reservation_number: str,
377 | reservation_query: ReservationQueryUseCase = Depends(
378 | Provide[AppContainer.reception.reservation_query]
379 | ),
380 | ):
381 | try:
382 | reservation: Reservation = reservation_query.get_reservation(
383 | reservation_number=reservation_number
384 | )
385 | except ReservationNotFoundException as e:
386 | raise HTTPException(
387 | status_code=status.HTTP_404_NOT_FOUND,
388 | detail=e.message,
389 | )
390 | return ReservationResponse(
391 | detail="ok",
392 | result=ReservationSchema.build(reservation=reservation),
393 | )
394 | ```
395 |
396 | - [application/use_case/query.py](src/reception/application/use_case/query.py)
397 | ```python
398 | class ReservationQueryUseCase:
399 | def __init__(
400 | self,
401 | reservation_repo: ReservationRDBRepository,
402 | db_session: Callable[[], ContextManager[Session]],
403 | ):
404 | self.reservation_repo = reservation_repo
405 | self.db_session = db_session
406 |
407 | def get_reservation(self, reservation_number: str) -> Reservation:
408 | reservation_number = ReservationNumber.from_value(value=reservation_number)
409 |
410 | with self.db_session() as session:
411 | reservation: Reservation | None = (
412 | self.reservation_repo.get_reservation_by_reservation_number(
413 | session=session, reservation_number=reservation_number
414 | )
415 | )
416 |
417 | if not reservation:
418 | raise ReservationNotFoundException
419 |
420 | return reservation
421 | ```
422 |
423 | - [infra/repository/repository.py](src/reception/infra/repository.py)
424 | ```python
425 | class ReservationRDBRepository(RDBRepository):
426 | @staticmethod
427 | def get_reservation_by_reservation_number(
428 | session: Session, reservation_number: ReservationNumber
429 | ) -> Reservation | None:
430 | return session.query(Reservation).filter_by(reservation_number=reservation_number).first()
431 | ```
432 |
433 | #### Schema
434 | Pydantic makes it easy to implement the request and response schema.
435 | - [presentation/rest/schema/request.py](src/reception/presentation/rest/request.py)
436 | ```python
437 | class CreateReservationRequest(BaseModel):
438 | room_number: str
439 | date_in: datetime
440 | date_out: datetime
441 | guest_mobile: mobile_type
442 | guest_name: str | None = None
443 | ```
444 |
445 | - [application/Schema/response.py](src/reception/presentation/rest/response.py)
446 | ```python
447 | class ReservationSchema(BaseModel):
448 | room: RoomSchema
449 | reservation_number: str
450 | status: ReservationStatus
451 | date_in: datetime
452 | date_out: datetime
453 | guest: GuestSchema
454 |
455 | @classmethod
456 | def build(cls, reservation: Reservation) -> ReservationSchema:
457 | return cls(
458 | room=RoomSchema.from_entity(reservation.room),
459 | reservation_number=reservation.reservation_number.value,
460 | status=reservation.reservation_status,
461 | date_in=reservation.date_in,
462 | date_out=reservation.date_out,
463 | guest=GuestSchema.from_entity(reservation.guest),
464 | )
465 |
466 | class ReservationResponse(BaseResponse):
467 | result: ReservationSchema
468 | ```
469 |
470 | #### Run server
471 | ```shell
472 | $ uvicorn shared_kernel.infra.fastapi.main:app --reload
473 | ```
474 |
475 | #### Requirements
476 | - Python 3.10+
477 | - 3.10 and lower versions can also take the key concepts
--------------------------------------------------------------------------------
/docs/image/bounded-context.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/docs/image/bounded-context.png
--------------------------------------------------------------------------------
/docs/image/erd.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/docs/image/erd.png
--------------------------------------------------------------------------------
/src/alembic.ini:
--------------------------------------------------------------------------------
1 | # A generic, single database configuration.
2 |
3 | [alembic]
4 | # path to migration scripts
5 | script_location = shared_kernel/infra/database/migrations
6 |
7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
8 | # Uncomment the line below if you want the files to be prepended with date and time
9 | # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
10 | # for all available tokens
11 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
12 |
13 | # sys.path path, will be prepended to sys.path if present.
14 | # defaults to the current working directory.
15 | prepend_sys_path = .
16 |
17 | # timezone to use when rendering the date within the migration file
18 | # as well as the filename.
19 | # If specified, requires the python-dateutil library that can be
20 | # installed by adding `alembic[tz]` to the pip requirements
21 | # string value is passed to dateutil.tz.gettz()
22 | # leave blank for localtime
23 | # timezone =
24 |
25 | # max length of characters to apply to the
26 | # "slug" field
27 | # truncate_slug_length = 40
28 |
29 | # set to 'true' to run the environment during
30 | # the 'revision' command, regardless of autogenerate
31 | # revision_environment = false
32 |
33 | # set to 'true' to allow .pyc and .pyo files without
34 | # a source .py file to be detected as revisions in the
35 | # versions/ directory
36 | # sourceless = false
37 |
38 | # version location specification; This defaults
39 | # to alembic/versions. When using multiple version
40 | # directories, initial revisions must be specified with --version-path.
41 | # The path separator used here should be the separator specified by "version_path_separator" below.
42 | # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
43 |
44 | # version path separator; As mentioned above, this is the character used to split
45 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
46 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
47 | # Valid values for version_path_separator are:
48 | #
49 | # version_path_separator = :
50 | # version_path_separator = ;
51 | # version_path_separator = space
52 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
53 |
54 | # the output encoding used when revision files
55 | # are written from script.py.mako
56 | # output_encoding = utf-8
57 |
58 | # sqlalchemy.url = driver://user:pass@localhost/dbname
59 |
60 |
61 | [post_write_hooks]
62 | # post_write_hooks defines scripts or Python functions that are run
63 | # on newly generated revision scripts. See the documentation for further
64 | # detail and examples
65 |
66 | # format using "black" - use the console_scripts runner, against the "black" entrypoint
67 | # hooks = black
68 | # black.type = console_scripts
69 | # black.entrypoint = black
70 | # black.options = -l 79 REVISION_SCRIPT_FILENAME
71 |
72 | # Logging configuration
73 | [loggers]
74 | keys = root,sqlalchemy,alembic
75 |
76 | [handlers]
77 | keys = console
78 |
79 | [formatters]
80 | keys = generic
81 |
82 | [logger_root]
83 | level = WARN
84 | handlers = console
85 | qualname =
86 |
87 | [logger_sqlalchemy]
88 | level = WARN
89 | handlers =
90 | qualname = sqlalchemy.engine
91 |
92 | [logger_alembic]
93 | level = INFO
94 | handlers =
95 | qualname = alembic
96 |
97 | [handler_console]
98 | class = StreamHandler
99 | args = (sys.stderr,)
100 | level = NOTSET
101 | formatter = generic
102 |
103 | [formatter_generic]
104 | format = %(levelname)-5.5s [%(name)s] %(message)s
105 | datefmt = %H:%M:%S
106 |
--------------------------------------------------------------------------------
/src/display/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/display/__init__.py
--------------------------------------------------------------------------------
/src/display/application/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/display/application/__init__.py
--------------------------------------------------------------------------------
/src/display/application/use_case/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/display/application/use_case/__init__.py
--------------------------------------------------------------------------------
/src/display/application/use_case/query.py:
--------------------------------------------------------------------------------
1 | from typing import Callable, ContextManager, List
2 |
3 | from sqlalchemy.orm import Query, Session
4 |
5 | from display.domain.entity.room import Room
6 | from display.infra.repository import RoomRDBRepository
7 | from shared_kernel.domain.value_object import RoomStatus
8 |
9 |
10 | class DisplayQueryUseCase:
11 | def __init__(self, room_repo: RoomRDBRepository, db_session: Callable[[], ContextManager[Session]]):
12 | self.room_repo = room_repo
13 | self.db_session = db_session
14 |
15 | def get_rooms(self, room_status: RoomStatus) -> List[Room]:
16 | with self.db_session() as session:
17 | rooms: List[Room] = list(
18 | self.room_repo.get_rooms_by_status(session=session, room_status=room_status)
19 | )
20 | return rooms
21 |
--------------------------------------------------------------------------------
/src/display/domain/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/display/domain/__init__.py
--------------------------------------------------------------------------------
/src/display/domain/entity/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/display/domain/entity/__init__.py
--------------------------------------------------------------------------------
/src/display/domain/entity/room.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass, field
2 |
3 | from shared_kernel.domain.entity import AggregateRoot
4 | from shared_kernel.domain.value_object import RoomStatus
5 |
6 |
7 | @dataclass(eq=False, slots=True)
8 | class Room(AggregateRoot):
9 | number: str
10 | room_status: RoomStatus
11 | image_url: str
12 | description: str | None = None
13 |
--------------------------------------------------------------------------------
/src/display/infra/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/display/infra/__init__.py
--------------------------------------------------------------------------------
/src/display/infra/container.py:
--------------------------------------------------------------------------------
1 | from dependency_injector import containers, providers
2 |
3 | from display.application.use_case.query import DisplayQueryUseCase
4 | from display.infra.repository import RoomRDBRepository
5 | from shared_kernel.infra.database.connection import get_db_session
6 |
7 |
8 | class DisplayContainer(containers.DeclarativeContainer):
9 | room_repo = providers.Factory(RoomRDBRepository)
10 |
11 | query = providers.Factory(
12 | DisplayQueryUseCase,
13 | room_repo=room_repo,
14 | db_session=get_db_session,
15 | )
16 |
--------------------------------------------------------------------------------
/src/display/infra/external_api/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/display/infra/external_api/__init__.py
--------------------------------------------------------------------------------
/src/display/infra/repository.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy.orm import Query, Session
2 |
3 | from display.domain.entity.room import Room
4 | from shared_kernel.domain.value_object import RoomStatus
5 | from shared_kernel.infra.database.repository import RDBReadRepository
6 |
7 |
8 | class RoomRDBRepository(RDBReadRepository):
9 | @staticmethod
10 | def get_rooms_by_status(session: Session, room_status: RoomStatus) -> Query:
11 | return session.query(Room).filter_by(status=room_status)
12 |
--------------------------------------------------------------------------------
/src/display/presentation/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/display/presentation/__init__.py
--------------------------------------------------------------------------------
/src/display/presentation/grpc/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/display/presentation/grpc/__init__.py
--------------------------------------------------------------------------------
/src/display/presentation/rest/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/display/presentation/rest/__init__.py
--------------------------------------------------------------------------------
/src/display/presentation/rest/api.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 | from dependency_injector.wiring import Provide, inject
4 | from fastapi import APIRouter, Depends
5 |
6 | from display.presentation.rest.request import GetRoomRequest
7 | from display.presentation.rest.response import RoomSchema, RoomResponse
8 | from display.application.use_case.query import DisplayQueryUseCase
9 | from display.domain.entity.room import Room
10 | from shared_kernel.infra.container import AppContainer
11 |
12 | router = APIRouter(prefix="/display")
13 |
14 |
15 | @router.get("/rooms")
16 | @inject
17 | def get_rooms(
18 | request: GetRoomRequest = Depends(),
19 | display_query: DisplayQueryUseCase = Depends(Provide[AppContainer.display.query]),
20 | ) -> RoomResponse:
21 | rooms: List[Room] = display_query.get_rooms(room_status=request.status)
22 | return RoomResponse(
23 | detail="ok",
24 | result=[RoomSchema.from_orm(room) for room in rooms]
25 | )
26 |
--------------------------------------------------------------------------------
/src/display/presentation/rest/request.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel
2 |
3 | from shared_kernel.domain.value_object import RoomStatus
4 |
5 |
6 | class GetRoomRequest(BaseModel):
7 | status: RoomStatus
8 |
--------------------------------------------------------------------------------
/src/display/presentation/rest/response.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 | from pydantic import BaseModel
4 |
5 | from shared_kernel.presentation.response import BaseResponse
6 | from shared_kernel.domain.value_object import RoomStatus
7 |
8 |
9 | class RoomSchema(BaseModel):
10 | id: int
11 | number: str
12 | status: RoomStatus
13 | image_url: str
14 | description: str | None
15 |
16 | class Config:
17 | orm_mode = True
18 |
19 |
20 | class RoomResponse(BaseResponse):
21 | result: List[RoomSchema]
22 |
--------------------------------------------------------------------------------
/src/pytest.ini:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/pytest.ini
--------------------------------------------------------------------------------
/src/reception/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/__init__.py
--------------------------------------------------------------------------------
/src/reception/application/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/application/__init__.py
--------------------------------------------------------------------------------
/src/reception/application/use_case/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/application/use_case/__init__.py
--------------------------------------------------------------------------------
/src/reception/application/use_case/command.py:
--------------------------------------------------------------------------------
1 | from typing import Callable, ContextManager
2 |
3 | from sqlalchemy.orm import Session
4 |
5 | from reception.presentation.rest.request import CreateReservationRequest, UpdateGuestRequest
6 | from reception.application.use_case.query import ReservationQueryUseCase
7 | from reception.domain.entity.reservation import Reservation
8 | from reception.domain.entity.room import Room
9 | from reception.domain.service.check_in import CheckInService
10 | from reception.domain.value_object.guest import Guest, mobile_type
11 | from reception.infra.repository import ReservationRDBRepository
12 |
13 |
14 | class ReservationCommandUseCase:
15 | def __init__(
16 | self,
17 | reservation_repo: ReservationRDBRepository,
18 | reservation_query: ReservationQueryUseCase,
19 | check_in_service: CheckInService,
20 | db_session: Callable[[], ContextManager[Session]],
21 | ):
22 | self.reservation_repo = reservation_repo
23 | self.reservation_query = reservation_query
24 | self.check_in_service = check_in_service
25 | self.db_session = db_session
26 |
27 | def make_reservation(self, request: CreateReservationRequest) -> Reservation:
28 | room: Room = self.reservation_query.get_room(room_number=request.room_number)
29 | reservation = Reservation.make(
30 | room=room,
31 | date_in=request.date_in,
32 | date_out=request.date_out,
33 | guest=Guest(mobile=request.guest_mobile, name=request.guest_name)
34 | )
35 | with self.db_session() as session:
36 | self.reservation_repo.add(session=session, instance=reservation)
37 | self.reservation_repo.commit(session=session)
38 | return reservation
39 |
40 | def update_guest_info(self, reservation_number: str, request: UpdateGuestRequest) -> Reservation:
41 | reservation: Reservation = self.reservation_query.get_reservation(reservation_number=reservation_number)
42 |
43 | guest: Guest = Guest(mobile=request.guest_mobile, name=request.guest_name)
44 | reservation.change_guest(guest=guest)
45 |
46 | with self.db_session() as session:
47 | self.reservation_repo.add(session=session, instance=reservation)
48 | self.reservation_repo.commit(session=session)
49 | return reservation
50 |
51 | def check_in(self, reservation_number: str, mobile: mobile_type) -> Reservation:
52 | reservation: Reservation = self.reservation_query.get_reservation(reservation_number=reservation_number)
53 | self.check_in_service.check_in(reservation=reservation, mobile=mobile)
54 |
55 | with self.db_session() as session:
56 | self.reservation_repo.add(session=session, instance=reservation)
57 | self.reservation_repo.commit(session=session)
58 | return reservation
59 |
60 | def check_out(self, reservation_number: str) -> Reservation:
61 | reservation: Reservation = self.reservation_query.get_reservation(reservation_number=reservation_number)
62 | reservation.check_out()
63 |
64 | with self.db_session() as session:
65 | self.reservation_repo.add(session=session, instance=reservation)
66 | self.reservation_repo.commit(session=session)
67 | return reservation
68 |
69 | def cancel(self, reservation_number: str) -> Reservation:
70 | reservation: Reservation = self.reservation_query.get_reservation(reservation_number=reservation_number)
71 | reservation.cancel()
72 |
73 | with self.db_session() as session:
74 | self.reservation_repo.add(session=session, instance=reservation)
75 | self.reservation_repo.commit(session=session)
76 | return reservation
77 |
--------------------------------------------------------------------------------
/src/reception/application/use_case/query.py:
--------------------------------------------------------------------------------
1 | from typing import Callable, ContextManager
2 |
3 | from sqlalchemy.orm import Session
4 |
5 | from reception.domain.entity.room import Room
6 | from reception.domain.exception.reservation import ReservationNotFoundException
7 | from reception.domain.entity.reservation import Reservation
8 | from reception.domain.exception.room import RoomNotFoundException
9 | from reception.domain.value_object.reservation import ReservationNumber
10 | from reception.infra.repository import ReservationRDBRepository
11 |
12 |
13 | class ReservationQueryUseCase:
14 | def __init__(
15 | self,
16 | reservation_repo: ReservationRDBRepository,
17 | db_session: Callable[[], ContextManager[Session]],
18 | ):
19 | self.reservation_repo = reservation_repo
20 | self.db_session = db_session
21 |
22 | def get_room(self, room_number: str) -> Room:
23 | with self.db_session() as session:
24 | room: Room | None = (
25 | self.reservation_repo.get_room_by_room_number(session=session, room_number=room_number)
26 | )
27 | if not room:
28 | raise RoomNotFoundException
29 | return room
30 |
31 | def get_reservation(self, reservation_number: str) -> Reservation:
32 | reservation_number = ReservationNumber.from_value(value=reservation_number)
33 |
34 | with self.db_session() as session:
35 | reservation: Reservation | None = (
36 | self.reservation_repo.get_reservation_by_reservation_number(
37 | session=session, reservation_number=reservation_number
38 | )
39 | )
40 |
41 | if not reservation:
42 | raise ReservationNotFoundException
43 | return reservation
44 |
--------------------------------------------------------------------------------
/src/reception/domain/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/domain/__init__.py
--------------------------------------------------------------------------------
/src/reception/domain/entity/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/domain/entity/__init__.py
--------------------------------------------------------------------------------
/src/reception/domain/entity/reservation.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from dataclasses import dataclass
4 | from datetime import datetime
5 |
6 | from reception.domain.exception.reservation import ReservationStatusException
7 | from reception.domain.exception.room import RoomStatusException
8 | from reception.domain.entity.room import Room
9 | from reception.domain.value_object.guest import Guest
10 | from reception.domain.value_object.reservation import ReservationNumber
11 | from shared_kernel.domain.entity import AggregateRoot
12 | from shared_kernel.domain.value_object import ReservationStatus, RoomStatus
13 |
14 |
15 | @dataclass(eq=False, slots=True)
16 | class Reservation(AggregateRoot):
17 | room: Room
18 | reservation_number: ReservationNumber
19 | reservation_status: ReservationStatus
20 | date_in: datetime
21 | date_out: datetime
22 | guest: Guest
23 |
24 | @classmethod
25 | def make(
26 | cls, room: Room, date_in: datetime, date_out: datetime, guest: Guest
27 | ) -> Reservation:
28 | room.reserve()
29 | return cls(
30 | room=room,
31 | date_in=date_in,
32 | date_out=date_out,
33 | guest=guest,
34 | reservation_number=ReservationNumber.generate(),
35 | reservation_status=ReservationStatus.IN_PROGRESS,
36 | )
37 |
38 | def cancel(self):
39 | if not self.reservation_status.in_progress:
40 | raise ReservationStatusException
41 |
42 | self.reservation_status = ReservationStatus.CANCELLED
43 | self.room.room_status = RoomStatus.AVAILABLE
44 |
45 | def check_in(self):
46 | if not self.room.room_status.is_reserved:
47 | raise RoomStatusException
48 |
49 | if not self.reservation_status.in_progress:
50 | raise ReservationStatusException
51 |
52 | self.room.room_status = RoomStatus.OCCUPIED
53 |
54 | def check_out(self):
55 | if not self.room.room_status.is_occupied:
56 | raise RoomStatusException
57 |
58 | if not self.reservation_status.in_progress:
59 | raise ReservationStatusException
60 |
61 | self.reservation_status = ReservationStatus.COMPLETE
62 | self.room.room_status = RoomStatus.AVAILABLE
63 |
64 | def change_guest(self, guest: Guest):
65 | self.guest = guest
66 |
--------------------------------------------------------------------------------
/src/reception/domain/entity/room.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass, field
2 |
3 | from reception.domain.exception.room import RoomStatusException
4 | from shared_kernel.domain.entity import Entity
5 | from shared_kernel.domain.value_object import RoomStatus
6 |
7 |
8 | @dataclass(eq=False, slots=True)
9 | class Room(Entity):
10 | number: str
11 | room_status: RoomStatus
12 |
13 | def reserve(self):
14 | if not self.room_status.is_available:
15 | raise RoomStatusException
16 |
17 | self.room_status = RoomStatus.RESERVED
18 |
--------------------------------------------------------------------------------
/src/reception/domain/exception/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/domain/exception/__init__.py
--------------------------------------------------------------------------------
/src/reception/domain/exception/check_in.py:
--------------------------------------------------------------------------------
1 | from shared_kernel.domain.exception import BaseMsgException
2 |
3 |
4 | class CheckInDateException(BaseMsgException):
5 | message = "Invalid date for check-in."
6 |
7 |
8 | class CheckInAuthenticationException(BaseMsgException):
9 | message = "Invalid guest authentication for check-in."
10 |
--------------------------------------------------------------------------------
/src/reception/domain/exception/reservation.py:
--------------------------------------------------------------------------------
1 | from shared_kernel.domain.exception import BaseMsgException
2 |
3 |
4 | class ReservationNotFoundException(BaseMsgException):
5 | message = "Reservation is not found."
6 |
7 |
8 | class ReservationStatusException(BaseMsgException):
9 | message = "Invalid request for current reservation status."
10 |
--------------------------------------------------------------------------------
/src/reception/domain/exception/room.py:
--------------------------------------------------------------------------------
1 | from shared_kernel.domain.exception import BaseMsgException
2 |
3 |
4 | class RoomNotFoundException(BaseMsgException):
5 | message = "Room is not found."
6 |
7 |
8 | class RoomStatusException(BaseMsgException):
9 | message = "Invalid request for current room status."
10 |
--------------------------------------------------------------------------------
/src/reception/domain/service/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/domain/service/__init__.py
--------------------------------------------------------------------------------
/src/reception/domain/service/check_in.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta
2 |
3 | from reception.domain.exception.check_in import CheckInAuthenticationException, CheckInDateException
4 | from reception.domain.entity.reservation import Reservation
5 | from reception.domain.value_object.guest import mobile_type
6 |
7 |
8 | class CheckInService:
9 | _EARLY_CHECK_IN_OFFSET: int = 3
10 | _LATE_CHECK_IN_OFFSET: int = 6
11 |
12 | @staticmethod
13 | def _is_valid_date(reservation: Reservation) -> bool:
14 | return (
15 | reservation.date_in - timedelta(hours=CheckInService._EARLY_CHECK_IN_OFFSET)
16 | <= datetime.utcnow()
17 | <= reservation.date_out - timedelta(hours=CheckInService._LATE_CHECK_IN_OFFSET)
18 | )
19 |
20 | @staticmethod
21 | def _is_valid_guest(reservation: Reservation, mobile: mobile_type) -> bool:
22 | # mobile authentication
23 | return reservation.guest.mobile == mobile
24 |
25 | def check_in(self, reservation: Reservation, mobile: str) -> None:
26 | if not self._is_valid_date(reservation=reservation):
27 | raise CheckInDateException
28 |
29 | if not self._is_valid_guest(reservation=reservation, mobile=mobile):
30 | raise CheckInAuthenticationException
31 |
32 | reservation.check_in()
33 |
--------------------------------------------------------------------------------
/src/reception/domain/value_object/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/domain/value_object/__init__.py
--------------------------------------------------------------------------------
/src/reception/domain/value_object/guest.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from dataclasses import dataclass
4 |
5 | from pydantic import constr
6 |
7 | from shared_kernel.domain.value_object import ValueObject
8 |
9 | mobile_type = constr(regex=r"\+[0-9]{2,3}-[0-9]{2}-[0-9]{4}-[0-9]{4}")
10 |
11 |
12 | @dataclass(slots=True)
13 | class Guest(ValueObject):
14 | mobile: mobile_type
15 | name: str | None = None
16 |
17 | def __composite_values__(self):
18 | return self.mobile, self.name
19 |
--------------------------------------------------------------------------------
/src/reception/domain/value_object/reservation.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import random
4 | import string
5 | from dataclasses import dataclass
6 | from datetime import datetime
7 | from typing import ClassVar
8 |
9 | from shared_kernel.domain.value_object import ValueObject
10 |
11 |
12 | @dataclass(slots=True)
13 | class ReservationNumber(ValueObject):
14 | _DATETIME_FORMAT: ClassVar[str] = "%y%m%d%H%M%S"
15 | _RANDOM_STR_LENGTH: ClassVar[int] = 7
16 |
17 | value: str
18 |
19 | @classmethod
20 | def generate(cls) -> ReservationNumber:
21 | time_part: str = datetime.utcnow().strftime(cls._DATETIME_FORMAT)
22 | random_strings: str = ''.join(
23 | random.choice(string.ascii_uppercase + string.digits) for _ in range(cls._RANDOM_STR_LENGTH)
24 | )
25 | return cls(value=time_part + ":" + random_strings)
26 |
--------------------------------------------------------------------------------
/src/reception/infra/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/infra/__init__.py
--------------------------------------------------------------------------------
/src/reception/infra/container.py:
--------------------------------------------------------------------------------
1 | from dependency_injector import containers, providers
2 |
3 | from reception.application.use_case.command import ReservationCommandUseCase
4 | from reception.application.use_case.query import ReservationQueryUseCase
5 | from reception.domain.service.check_in import CheckInService
6 | from reception.infra.repository import ReservationRDBRepository
7 | from shared_kernel.infra.database.connection import get_db_session
8 |
9 |
10 | class ReceptionContainer(containers.DeclarativeContainer):
11 | reservation_repo = providers.Factory(ReservationRDBRepository)
12 |
13 | check_in_service = providers.Factory(CheckInService)
14 |
15 | reservation_query = providers.Factory(
16 | ReservationQueryUseCase,
17 | reservation_repo=reservation_repo,
18 | db_session=get_db_session,
19 | )
20 | reservation_command = providers.Factory(
21 | ReservationCommandUseCase,
22 | reservation_repo=reservation_repo,
23 | reservation_query=reservation_query,
24 | check_in_service=check_in_service,
25 | db_session=get_db_session,
26 | )
27 |
--------------------------------------------------------------------------------
/src/reception/infra/external_api/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/infra/external_api/__init__.py
--------------------------------------------------------------------------------
/src/reception/infra/repository.py:
--------------------------------------------------------------------------------
1 | from reception.domain.entity.reservation import Reservation
2 | from reception.domain.entity.room import Room
3 | from reception.domain.value_object.reservation import ReservationNumber
4 | from shared_kernel.infra.database.repository import RDBRepository
5 |
6 |
7 | class ReservationRDBRepository(RDBRepository):
8 | @staticmethod
9 | def get_reservation_by_reservation_number(session, reservation_number: ReservationNumber) -> Reservation | None:
10 | return session.query(Reservation).filter_by(reservation_number=reservation_number).first()
11 |
12 | @staticmethod
13 | def get_room_by_room_number(session, room_number: str) -> Room | None:
14 | return session.query(Room).filter_by(number=room_number).first()
15 |
--------------------------------------------------------------------------------
/src/reception/presentation/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/presentation/__init__.py
--------------------------------------------------------------------------------
/src/reception/presentation/grpc/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/presentation/grpc/__init__.py
--------------------------------------------------------------------------------
/src/reception/presentation/rest/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/reception/presentation/rest/__init__.py
--------------------------------------------------------------------------------
/src/reception/presentation/rest/api.py:
--------------------------------------------------------------------------------
1 | from dependency_injector.wiring import Provide, inject
2 | from fastapi import APIRouter, Body, Depends, HTTPException
3 | from starlette import status
4 |
5 | from reception.application.use_case.command import ReservationCommandUseCase
6 | from reception.application.use_case.query import ReservationQueryUseCase
7 | from reception.domain.exception.check_in import CheckInAuthenticationException, CheckInDateException
8 | from reception.domain.exception.reservation import ReservationNotFoundException, ReservationStatusException
9 | from reception.domain.exception.room import RoomNotFoundException, RoomStatusException
10 | from reception.domain.entity.reservation import Reservation
11 | from reception.presentation.rest.request import CheckInRequest, CreateReservationRequest, UpdateGuestRequest
12 | from reception.presentation.rest.response import ReservationSchema, ReservationResponse
13 | from shared_kernel.presentation.response import BaseResponse
14 | from shared_kernel.infra.container import AppContainer
15 |
16 | router = APIRouter(prefix="/reception")
17 |
18 |
19 | @router.post(
20 | "/reservations",
21 | status_code=status.HTTP_201_CREATED,
22 | responses={
23 | status.HTTP_201_CREATED: {"model": ReservationResponse},
24 | status.HTTP_409_CONFLICT: {"model": BaseResponse},
25 | }
26 | )
27 | @inject
28 | def post_reservations(
29 | create_reservation_request: CreateReservationRequest = Body(),
30 | reservation_command: ReservationCommandUseCase = Depends(Provide[AppContainer.reception.reservation_command]),
31 | ) -> ReservationResponse:
32 | try:
33 | reservation: Reservation = reservation_command.make_reservation(request=create_reservation_request)
34 | except RoomNotFoundException as e:
35 | raise HTTPException(
36 | status_code=status.HTTP_404_NOT_FOUND,
37 | detail=e.message,
38 | )
39 | except (RoomStatusException, ReservationStatusException) as e:
40 | raise HTTPException(
41 | status_code=status.HTTP_409_CONFLICT,
42 | detail=e.message,
43 | )
44 |
45 | return ReservationResponse(
46 | detail="ok",
47 | result=ReservationSchema.build(reservation=reservation),
48 | )
49 |
50 |
51 | @router.get(
52 | "/reservations/{reservation_number}",
53 | status_code=status.HTTP_200_OK,
54 | responses={
55 | status.HTTP_200_OK: {"model": ReservationResponse},
56 | status.HTTP_404_NOT_FOUND: {"model": BaseResponse},
57 | }
58 | )
59 | @inject
60 | def get_reservation(
61 | reservation_number: str,
62 | reservation_query: ReservationQueryUseCase = Depends(Provide[AppContainer.reception.reservation_query]),
63 | ) -> ReservationResponse:
64 | try:
65 | reservation: Reservation = reservation_query.get_reservation(reservation_number=reservation_number)
66 | except ReservationNotFoundException as e:
67 | raise HTTPException(
68 | status_code=status.HTTP_404_NOT_FOUND,
69 | detail=e.message,
70 | )
71 |
72 | return ReservationResponse(
73 | detail="ok",
74 | result=ReservationSchema.build(reservation=reservation),
75 | )
76 |
77 |
78 | @router.patch(
79 | "/reservations/{reservation_number}/guest",
80 | status_code=status.HTTP_200_OK,
81 | responses={
82 | status.HTTP_200_OK: {"model": ReservationResponse},
83 | status.HTTP_404_NOT_FOUND: {"model": BaseResponse},
84 | status.HTTP_409_CONFLICT: {"model": BaseResponse},
85 | }
86 | )
87 | @inject
88 | def patch_reservation(
89 | reservation_number: str,
90 | update_quest_request: UpdateGuestRequest = Body(),
91 | reservation_command: ReservationCommandUseCase = Depends(Provide[AppContainer.reception.reservation_command]),
92 | ) -> ReservationResponse:
93 | try:
94 | reservation: Reservation = reservation_command.update_guest_info(
95 | reservation_number=reservation_number, request=update_quest_request
96 | )
97 | except ReservationNotFoundException as e:
98 | raise HTTPException(
99 | status_code=status.HTTP_404_NOT_FOUND,
100 | detail=e.message,
101 | )
102 | except (RoomStatusException, ReservationStatusException) as e:
103 | raise HTTPException(
104 | status_code=status.HTTP_409_CONFLICT,
105 | detail=e.message,
106 | )
107 |
108 | return ReservationResponse(
109 | detail="ok",
110 | result=ReservationSchema.build(reservation=reservation),
111 | )
112 |
113 |
114 | @router.post(
115 | "/reservations/{reservation_number}/check-in",
116 | status_code=status.HTTP_200_OK,
117 | responses={
118 | status.HTTP_200_OK: {"model": ReservationResponse},
119 | status.HTTP_400_BAD_REQUEST: {"model": BaseResponse},
120 | status.HTTP_404_NOT_FOUND: {"model": BaseResponse},
121 | status.HTTP_409_CONFLICT: {"model": BaseResponse},
122 | }
123 | )
124 | @inject
125 | def post_reservation_check_in(
126 | reservation_number: str,
127 | check_in_request: CheckInRequest = Body(),
128 | reservation_command: ReservationCommandUseCase = Depends(Provide[AppContainer.reception.reservation_command]),
129 | ) -> ReservationResponse:
130 | try:
131 | reservation: Reservation = reservation_command.check_in(
132 | reservation_number=reservation_number, mobile=check_in_request.mobile
133 | )
134 | except (CheckInDateException, CheckInAuthenticationException) as e:
135 | raise HTTPException(
136 | status_code=status.HTTP_400_BAD_REQUEST,
137 | detail=e.message,
138 | )
139 | except ReservationNotFoundException as e:
140 | raise HTTPException(
141 | status_code=status.HTTP_404_NOT_FOUND,
142 | detail=e.message,
143 | )
144 | except (RoomStatusException, ReservationStatusException) as e:
145 | raise HTTPException(
146 | status_code=status.HTTP_409_CONFLICT,
147 | detail=e.message,
148 | )
149 |
150 | return ReservationResponse(
151 | detail="ok",
152 | result=ReservationSchema.build(reservation=reservation),
153 | )
154 |
155 |
156 | @router.post(
157 | "/reservations/{reservation_number}/check-out",
158 | status_code=status.HTTP_200_OK,
159 | responses={
160 | status.HTTP_200_OK: {"model": ReservationResponse},
161 | status.HTTP_404_NOT_FOUND: {"model": BaseResponse},
162 | status.HTTP_409_CONFLICT: {"model": BaseResponse},
163 | }
164 | )
165 | @inject
166 | def post_reservation_check_out(
167 | reservation_number: str,
168 | reservation_command: ReservationCommandUseCase = Depends(Provide[AppContainer.reception.reservation_command]),
169 | ) -> ReservationResponse:
170 | try:
171 | reservation: Reservation = reservation_command.check_out(reservation_number=reservation_number)
172 | except ReservationNotFoundException as e:
173 | raise HTTPException(
174 | status_code=status.HTTP_404_NOT_FOUND,
175 | detail=e.message,
176 | )
177 | except (RoomStatusException, ReservationStatusException) as e:
178 | raise HTTPException(
179 | status_code=status.HTTP_409_CONFLICT,
180 | detail=e.message,
181 | )
182 |
183 | return ReservationResponse(
184 | detail="ok",
185 | result=ReservationSchema.build(reservation=reservation),
186 | )
187 |
188 |
189 | @router.post("/reservations/{reservation_number}/cancel")
190 | @inject
191 | def post_reservation_cancel(
192 | reservation_number: str,
193 | reservation_command: ReservationCommandUseCase = Depends(Provide[AppContainer.reception.reservation_command]),
194 | ) -> ReservationResponse:
195 | try:
196 | reservation: Reservation = reservation_command.cancel(reservation_number=reservation_number)
197 | except ReservationNotFoundException as e:
198 | raise HTTPException(
199 | status_code=status.HTTP_404_NOT_FOUND,
200 | detail=e.message,
201 | )
202 | except (RoomStatusException, ReservationStatusException) as e:
203 | raise HTTPException(
204 | status_code=status.HTTP_409_CONFLICT,
205 | detail=e.message,
206 | )
207 |
208 | return ReservationResponse(
209 | detail="ok",
210 | result=ReservationSchema.build(reservation=reservation),
211 | )
212 |
--------------------------------------------------------------------------------
/src/reception/presentation/rest/request.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from pydantic import BaseModel
4 |
5 | from reception.domain.value_object.guest import mobile_type
6 |
7 |
8 | class CreateReservationRequest(BaseModel):
9 | room_number: str
10 | date_in: datetime
11 | date_out: datetime
12 | guest_mobile: mobile_type
13 | guest_name: str | None = None
14 |
15 |
16 | class UpdateGuestRequest(BaseModel):
17 | guest_mobile: mobile_type
18 | guest_name: str | None = None
19 |
20 |
21 | class CheckInRequest(BaseModel):
22 | mobile: mobile_type
23 |
--------------------------------------------------------------------------------
/src/reception/presentation/rest/response.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from datetime import datetime
4 |
5 | from pydantic import BaseModel
6 |
7 | from reception.domain.entity.reservation import Reservation
8 | from reception.domain.entity.room import Room
9 | from reception.domain.value_object.guest import Guest, mobile_type
10 | from shared_kernel.presentation.response import BaseResponse
11 | from shared_kernel.domain.value_object import ReservationStatus, RoomStatus
12 |
13 |
14 | class RoomSchema(BaseModel):
15 | number: str
16 | status: RoomStatus
17 |
18 | @classmethod
19 | def from_entity(cls, room: Room) -> RoomSchema:
20 | return cls(
21 | number=room.number,
22 | status=room.room_status,
23 | )
24 |
25 |
26 | class GuestSchema(BaseModel):
27 | mobile: mobile_type
28 | name: str | None = None
29 |
30 | @classmethod
31 | def from_entity(cls, guest: Guest) -> GuestSchema:
32 | return cls(
33 | mobile=guest.mobile,
34 | name=guest.name,
35 | )
36 |
37 |
38 | class ReservationSchema(BaseModel):
39 | room: RoomSchema
40 | reservation_number: str
41 | status: ReservationStatus
42 | date_in: datetime
43 | date_out: datetime
44 | guest: GuestSchema
45 |
46 | @classmethod
47 | def build(cls, reservation: Reservation) -> ReservationSchema:
48 | return cls(
49 | room=RoomSchema.from_entity(reservation.room),
50 | reservation_number=reservation.reservation_number.value,
51 | status=reservation.reservation_status.value,
52 | date_in=reservation.date_in,
53 | date_out=reservation.date_out,
54 | guest=GuestSchema.from_entity(reservation.guest),
55 | )
56 |
57 |
58 | class ReservationResponse(BaseResponse):
59 | result: ReservationSchema
60 |
--------------------------------------------------------------------------------
/src/requirements.txt:
--------------------------------------------------------------------------------
1 | alembic==1.8.1
2 | anyio==3.6.2
3 | attrs==22.1.0
4 | cffi==1.15.1
5 | click==8.1.3
6 | cryptography==38.0.3
7 | dependency-injector==4.41.0
8 | exceptiongroup==1.0.0
9 | fastapi==0.85.1
10 | greenlet==1.1.3.post0
11 | h11==0.14.0
12 | httptools==0.5.0
13 | idna==3.4
14 | iniconfig==1.1.1
15 | isort==5.10.1
16 | Mako==1.2.3
17 | MarkupSafe==2.1.1
18 | packaging==21.3
19 | pluggy==1.0.0
20 | pycparser==2.21
21 | pydantic==1.10.2
22 | PyMySQL==1.0.2
23 | pyparsing==3.0.9
24 | pytest==7.2.0
25 | python-dotenv==0.21.0
26 | PyYAML==6.0
27 | six==1.16.0
28 | sniffio==1.3.0
29 | SQLAlchemy==1.4.42
30 | SQLAlchemy-Utils==0.38.3
31 | starlette==0.20.4
32 | tomli==2.0.1
33 | typing_extensions==4.4.0
34 | uvicorn==0.19.0
35 | uvloop==0.17.0
36 | watchfiles==0.18.0
37 | websockets==10.4
38 | requests
39 | pytest-mock
40 | schema
--------------------------------------------------------------------------------
/src/shared_kernel/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/shared_kernel/__init__.py
--------------------------------------------------------------------------------
/src/shared_kernel/domain/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/shared_kernel/domain/__init__.py
--------------------------------------------------------------------------------
/src/shared_kernel/domain/entity.py:
--------------------------------------------------------------------------------
1 | from dataclasses import field
2 | from typing import Any, TypeVar
3 |
4 | EntityType = TypeVar("EntityType", bound="Entity")
5 |
6 |
7 | class Entity:
8 | id: int = field(init=False)
9 |
10 | def __eq__(self, other: Any) -> bool:
11 | if isinstance(other, type(self)):
12 | return self.id == other.id
13 | return False
14 |
15 | def __hash__(self):
16 | return hash(self.id)
17 |
18 |
19 | class AggregateRoot(Entity):
20 | """
21 | An entry point of aggregate.
22 | """
23 | pass
24 |
--------------------------------------------------------------------------------
/src/shared_kernel/domain/exception.py:
--------------------------------------------------------------------------------
1 | class ValueObjectEnumError(Exception):
2 | def __str__(self):
3 | return "Value Object got invalid value."
4 |
5 |
6 | class BaseMsgException(Exception):
7 | message: str
8 |
9 | def __str__(self):
10 | return self.message
11 |
--------------------------------------------------------------------------------
/src/shared_kernel/domain/value_object.py:
--------------------------------------------------------------------------------
1 | from enum import Enum, EnumMeta
2 | from typing import Any, TypeVar
3 |
4 | from shared_kernel.domain.exception import ValueObjectEnumError
5 |
6 | ValueObjectType = TypeVar("ValueObjectType", bound="ValueObject")
7 |
8 |
9 | class ValueObject:
10 | def __composite_values__(self):
11 | return self.value,
12 |
13 | @classmethod
14 | def from_value(cls, value: Any) -> ValueObjectType:
15 | if isinstance(cls, EnumMeta):
16 | for item in cls:
17 | if item.value == value:
18 | return item
19 | raise ValueObjectEnumError
20 |
21 | instance = cls(value=value)
22 | return instance
23 |
24 |
25 | class RoomStatus(ValueObject, str, Enum):
26 | AVAILABLE = "AVAILABLE"
27 | RESERVED = "RESERVED"
28 | OCCUPIED = "OCCUPIED"
29 |
30 | @property
31 | def is_available(self) -> bool:
32 | return self == RoomStatus.AVAILABLE
33 |
34 | @property
35 | def is_reserved(self) -> bool:
36 | return self == RoomStatus.RESERVED
37 |
38 | @property
39 | def is_occupied(self) -> bool:
40 | return self == RoomStatus.OCCUPIED
41 |
42 |
43 | class ReservationStatus(ValueObject, str, Enum):
44 | IN_PROGRESS = "IN-PROGRESS"
45 | CANCELLED = "CANCELLED"
46 | COMPLETE = "COMPLETE"
47 |
48 | @property
49 | def in_progress(self) -> bool:
50 | return self == ReservationStatus.IN_PROGRESS
51 |
--------------------------------------------------------------------------------
/src/shared_kernel/infra/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/shared_kernel/infra/__init__.py
--------------------------------------------------------------------------------
/src/shared_kernel/infra/container.py:
--------------------------------------------------------------------------------
1 | from dependency_injector import containers, providers
2 |
3 | from display.infra.container import DisplayContainer
4 | from reception.infra.container import ReceptionContainer
5 |
6 |
7 | class AppContainer(containers.DeclarativeContainer):
8 | wiring_config = containers.WiringConfiguration(
9 | modules=[
10 | "display.presentation.rest.api",
11 | "reception.presentation.rest.api"
12 | ]
13 | )
14 |
15 | display = providers.Container(DisplayContainer)
16 | reception = providers.Container(ReceptionContainer)
17 |
--------------------------------------------------------------------------------
/src/shared_kernel/infra/database/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/shared_kernel/infra/database/__init__.py
--------------------------------------------------------------------------------
/src/shared_kernel/infra/database/connection.py:
--------------------------------------------------------------------------------
1 | from contextlib import contextmanager
2 |
3 | from sqlalchemy import create_engine
4 | from sqlalchemy.orm import sessionmaker
5 | from sqlalchemy_utils import create_database, database_exists
6 |
7 | from shared_kernel.infra.fastapi.config import settings
8 |
9 |
10 | def get_engine():
11 | db_engine = create_engine(settings.SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
12 |
13 | if not database_exists(db_engine.url):
14 | create_database(db_engine.url)
15 |
16 | return db_engine
17 |
18 |
19 | engine = get_engine()
20 | SessionFactory = sessionmaker(autocommit=False, autoflush=False, expire_on_commit=False, bind=engine)
21 |
22 |
23 | @contextmanager
24 | def get_db_session():
25 | db = SessionFactory()
26 | try:
27 | yield db
28 | finally:
29 | db.close()
30 |
--------------------------------------------------------------------------------
/src/shared_kernel/infra/database/migrations/README:
--------------------------------------------------------------------------------
1 | Generic single-database configuration.
--------------------------------------------------------------------------------
/src/shared_kernel/infra/database/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/shared_kernel/infra/database/migrations/__init__.py
--------------------------------------------------------------------------------
/src/shared_kernel/infra/database/migrations/env.py:
--------------------------------------------------------------------------------
1 | from logging.config import fileConfig
2 |
3 | from alembic import context
4 | from sqlalchemy import engine_from_config, pool
5 |
6 | # this is the Alembic Config object, which provides
7 | # access to the values within the .ini file in use.
8 | from shared_kernel.infra.database.orm import metadata
9 | from shared_kernel.infra.fastapi.config import settings
10 |
11 | config = context.config
12 |
13 | # Interpret the config file for Python logging.
14 | # This line sets up loggers basically.
15 | if config.config_file_name is not None:
16 | fileConfig(config.config_file_name)
17 |
18 | # add your model's MetaData object here
19 | # for 'autogenerate' support
20 | # from myapp import mymodel
21 | # target_metadata = mymodel.Base.metadata
22 | target_metadata = metadata
23 |
24 | # other values from the config, defined by the needs of env.py,
25 | # can be acquired:
26 | # my_important_option = config.get_main_option("my_important_option")
27 | # ... etc.
28 |
29 |
30 | database_url = settings.SQLALCHEMY_DATABASE_URL
31 |
32 |
33 | def run_migrations_offline() -> None:
34 | """Run migrations in 'offline' mode.
35 |
36 | This configures the context with just a URL
37 | and not an Engine, though an Engine is acceptable
38 | here as well. By skipping the Engine creation
39 | we don't even need a DBAPI to be available.
40 |
41 | Calls to context.execute() here emit the given string to the
42 | script output.
43 |
44 | """
45 | context.configure(
46 | url=database_url,
47 | target_metadata=target_metadata,
48 | literal_binds=True,
49 | dialect_opts={"paramstyle": "named"},
50 | )
51 |
52 | with context.begin_transaction():
53 | context.run_migrations()
54 |
55 |
56 | def run_migrations_online() -> None:
57 | """Run migrations in 'online' mode.
58 |
59 | In this scenario we need to create an Engine
60 | and associate a connection with the context.
61 |
62 | """
63 | configuration = config.get_section(config.config_ini_section)
64 | configuration["sqlalchemy.url"] = database_url
65 | connectable = engine_from_config(
66 | configuration,
67 | prefix="sqlalchemy.",
68 | poolclass=pool.NullPool,
69 | )
70 |
71 | with connectable.connect() as connection:
72 | context.configure(
73 | connection=connection, target_metadata=target_metadata
74 | )
75 |
76 | with context.begin_transaction():
77 | context.run_migrations()
78 |
79 |
80 | if context.is_offline_mode():
81 | run_migrations_offline()
82 | else:
83 | run_migrations_online()
84 |
--------------------------------------------------------------------------------
/src/shared_kernel/infra/database/migrations/script.py.mako:
--------------------------------------------------------------------------------
1 | """${message}
2 |
3 | Revision ID: ${up_revision}
4 | Revises: ${down_revision | comma,n}
5 | Create Date: ${create_date}
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 | ${imports if imports else ""}
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = ${repr(up_revision)}
14 | down_revision = ${repr(down_revision)}
15 | branch_labels = ${repr(branch_labels)}
16 | depends_on = ${repr(depends_on)}
17 |
18 |
19 | def upgrade() -> None:
20 | ${upgrades if upgrades else "pass"}
21 |
22 |
23 | def downgrade() -> None:
24 | ${downgrades if downgrades else "pass"}
25 |
--------------------------------------------------------------------------------
/src/shared_kernel/infra/database/migrations/versions/6b595c7689ad_init.py:
--------------------------------------------------------------------------------
1 | """init
2 |
3 | Revision ID: 6b595c7689ad
4 | Revises:
5 | Create Date: 2022-11-05 09:16:27.850505
6 |
7 | """
8 | import sqlalchemy as sa
9 | from alembic import op
10 |
11 | # revision identifiers, used by Alembic.
12 | revision = '6b595c7689ad'
13 | down_revision = None
14 | branch_labels = None
15 | depends_on = None
16 |
17 |
18 | def upgrade() -> None:
19 | # ### commands auto generated by Alembic - please adjust! ###
20 | op.create_table('hotel_room',
21 | sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
22 | sa.Column('number', sa.String(length=20), nullable=False),
23 | sa.Column('status', sa.String(length=20), nullable=False),
24 | sa.Column('image_url', sa.String(length=200), nullable=False),
25 | sa.Column('description', sa.Text(), nullable=True),
26 | sa.PrimaryKeyConstraint('id'),
27 | sa.UniqueConstraint('number', name='uix_hotel_room_number')
28 | )
29 | op.create_table('room_reservation',
30 | sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
31 | sa.Column('room_id', sa.Integer(), nullable=False),
32 | sa.Column('number', sa.String(length=20), nullable=False),
33 | sa.Column('status', sa.String(length=20), nullable=False),
34 | sa.Column('date_in', sa.DateTime(timezone=True), nullable=True),
35 | sa.Column('date_out', sa.DateTime(timezone=True), nullable=True),
36 | sa.Column('guest_mobile', sa.String(length=20), nullable=False),
37 | sa.Column('guest_name', sa.String(length=50), nullable=True),
38 | sa.ForeignKeyConstraint(['room_id'], ['hotel_room.id'], ),
39 | sa.PrimaryKeyConstraint('id')
40 | )
41 | # ### end Alembic commands ###
42 |
43 |
44 | def downgrade() -> None:
45 | # ### commands auto generated by Alembic - please adjust! ###
46 | op.drop_table('room_reservation')
47 | op.drop_table('hotel_room')
48 | # ### end Alembic commands ###
49 |
--------------------------------------------------------------------------------
/src/shared_kernel/infra/database/migrations/versions/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/shared_kernel/infra/database/migrations/versions/__init__.py
--------------------------------------------------------------------------------
/src/shared_kernel/infra/database/orm.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import Column, DateTime, ForeignKey, Integer, MetaData, String, Table, Text, UniqueConstraint
2 | from sqlalchemy.orm import composite, registry, relationship
3 |
4 | from reception.domain.entity.room import Room
5 | from reception.domain.value_object.guest import Guest
6 | from reception.domain.value_object.reservation import ReservationNumber
7 | from shared_kernel.domain.value_object import ReservationStatus, RoomStatus
8 |
9 | metadata = MetaData()
10 | mapper_registry = registry()
11 |
12 |
13 | room_table = Table(
14 | "hotel_room",
15 | metadata,
16 | Column("id", Integer, primary_key=True, autoincrement=True),
17 | Column("number", String(20), nullable=False),
18 | Column("status", String(20), nullable=False),
19 | Column("image_url", String(200), nullable=False),
20 | Column("description", Text, nullable=True),
21 | UniqueConstraint("number", name="uix_hotel_room_number"),
22 | )
23 |
24 | reservation_table = Table(
25 | "room_reservation",
26 | metadata,
27 | Column("id", Integer, primary_key=True, autoincrement=True),
28 | Column("room_id", Integer, ForeignKey("hotel_room.id"), nullable=False),
29 | Column("number", String(20), nullable=False),
30 | Column("status", String(20), nullable=False),
31 | Column("date_in", DateTime(timezone=True)),
32 | Column("date_out", DateTime(timezone=True)),
33 | Column("guest_mobile", String(20), nullable=False),
34 | Column("guest_name", String(50), nullable=True),
35 | )
36 |
37 |
38 | def init_orm_mappers():
39 | """
40 | initialize orm mappings
41 | """
42 | from reception.domain.entity.reservation import Reservation as ReceptionReservationEntity
43 | from reception.domain.entity.room import Room as ReceptionRoomEntity
44 |
45 | mapper_registry.map_imperatively(
46 | ReceptionRoomEntity,
47 | room_table,
48 | properties={
49 | "room_status": composite(RoomStatus.from_value, room_table.c.status),
50 | }
51 | )
52 | mapper_registry.map_imperatively(
53 | ReceptionReservationEntity,
54 | reservation_table,
55 | properties={
56 | "room": relationship(Room, backref="reservations", order_by=reservation_table.c.id.desc, lazy="joined"),
57 | "reservation_number": composite(ReservationNumber.from_value, reservation_table.c.number),
58 | "reservation_status": composite(ReservationStatus.from_value, reservation_table.c.status),
59 | "guest": composite(Guest, reservation_table.c.guest_mobile, reservation_table.c.guest_name),
60 | }
61 | )
62 |
63 | from display.domain.entity.room import Room as DisplayRoomEntity
64 |
65 | mapper_registry.map_imperatively(
66 | DisplayRoomEntity,
67 | room_table,
68 | properties={
69 | "room_status": composite(RoomStatus.from_value, room_table.c.status),
70 | }
71 | )
72 |
--------------------------------------------------------------------------------
/src/shared_kernel/infra/database/repository.py:
--------------------------------------------------------------------------------
1 | from shared_kernel.domain.entity import EntityType
2 |
3 |
4 | class RDBRepository:
5 | @staticmethod
6 | def add(session, instance: EntityType):
7 | return session.add(instance)
8 |
9 | @staticmethod
10 | def commit(session):
11 | return session.commit()
12 |
13 |
14 | class RDBReadRepository:
15 | pass
16 |
--------------------------------------------------------------------------------
/src/shared_kernel/infra/fastapi/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/shared_kernel/infra/fastapi/__init__.py
--------------------------------------------------------------------------------
/src/shared_kernel/infra/fastapi/config.py:
--------------------------------------------------------------------------------
1 | from typing import ClassVar
2 |
3 | from pydantic import BaseSettings
4 |
5 |
6 | class Settings(BaseSettings):
7 | DRIVER: ClassVar[str] = "mysql+pymysql"
8 | USERNAME: ClassVar[str] = "admin"
9 | PASSWORD: ClassVar[str] = "ddd-hotel"
10 | HOST: ClassVar[str] = "127.0.0.1"
11 | PORT: ClassVar[str] = 3306
12 | DATABASE: ClassVar[str] = "ddd-hotel"
13 |
14 | SQLALCHEMY_DATABASE_URL: ClassVar[str] = f"{DRIVER}://{USERNAME}:{PASSWORD}@{HOST}:{PORT}/{DATABASE}"
15 |
16 | class Config:
17 | env_file = ".env"
18 |
19 |
20 | settings = Settings()
21 |
--------------------------------------------------------------------------------
/src/shared_kernel/infra/fastapi/main.py:
--------------------------------------------------------------------------------
1 | from fastapi import FastAPI
2 |
3 | from display.presentation.rest import api as display_api
4 | from reception.presentation.rest import api as reception_api
5 | from shared_kernel.infra.container import AppContainer
6 | from shared_kernel.infra.database.orm import init_orm_mappers
7 |
8 | app_container = AppContainer()
9 |
10 | app = FastAPI(
11 | title="Python-DDD-Hotel",
12 | contact={
13 | "name": "qu3vipon",
14 | "email": "qu3vipon@gmail.com",
15 | },
16 | )
17 |
18 | app.container = app_container
19 | app.include_router(reception_api.router)
20 | app.include_router(display_api.router)
21 |
22 | init_orm_mappers()
23 |
24 |
25 | @app.get("/")
26 | def health_check():
27 | return {"ping": "pong"}
28 |
--------------------------------------------------------------------------------
/src/shared_kernel/infra/log/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/shared_kernel/infra/log/__init__.py
--------------------------------------------------------------------------------
/src/shared_kernel/presentation/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/shared_kernel/presentation/__init__.py
--------------------------------------------------------------------------------
/src/shared_kernel/presentation/response.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from pydantic import BaseModel, Field
4 |
5 |
6 | class BaseResponse(BaseModel):
7 | detail: str = Field(...)
8 | result: Any
9 |
--------------------------------------------------------------------------------
/src/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/tests/__init__.py
--------------------------------------------------------------------------------
/src/tests/conftest.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, date
2 |
3 | import pytest
4 | from fastapi.testclient import TestClient
5 | from schema import And, Use
6 |
7 | from shared_kernel.infra.fastapi.main import app
8 |
9 |
10 | @pytest.fixture
11 | def client():
12 | return TestClient(app)
13 |
14 |
15 | valid_datetime = And(Use(lambda s: datetime.strptime(s, "%Y-%m-%dT%H:%M:%S")), datetime)
16 | valid_date = And(Use(lambda s: datetime.strptime(s, "%Y-%m-%d").date()), date)
17 |
--------------------------------------------------------------------------------
/src/tests/functional/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/tests/functional/__init__.py
--------------------------------------------------------------------------------
/src/tests/functional/test_display.py:
--------------------------------------------------------------------------------
1 | from schema import Schema, Or
2 |
3 | from display.domain.entity.room import Room
4 | from shared_kernel.domain.value_object import RoomStatus
5 |
6 |
7 | def test_get_rooms(client, mocker):
8 | # given
9 | room_available = Room(number="A", room_status=RoomStatus.AVAILABLE, image_url="img1")
10 | room_available.id = 1 # Assume that it is allocated from db
11 |
12 | display_query = mocker.MagicMock()
13 | display_query.get_rooms.return_value = [room_available]
14 |
15 | with client.app.container.display.query.override(display_query):
16 | # when
17 | response = client.get("/display/rooms", params={"status": RoomStatus.AVAILABLE})
18 |
19 | # then
20 | display_query.get_rooms.assert_called_once_with(room_status=RoomStatus.AVAILABLE)
21 |
22 | schema = Schema(
23 | {
24 | "detail": "ok",
25 | "result": [
26 | {
27 | "id": 1,
28 | "number": "A",
29 | "status": RoomStatus.AVAILABLE,
30 | "image_url": "img1",
31 | "description": Or(str, None),
32 | }
33 | ]
34 | }
35 | )
36 |
37 | assert schema.is_valid(response.json())
38 |
--------------------------------------------------------------------------------
/src/tests/functional/test_reception.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from schema import Schema
4 |
5 | from reception.domain.entity.reservation import Reservation
6 | from reception.domain.entity.room import Room
7 | from reception.domain.value_object.guest import Guest
8 | from reception.domain.value_object.reservation import ReservationNumber
9 | from shared_kernel.domain.value_object import RoomStatus, ReservationStatus
10 |
11 |
12 | def test_create_reservation(client, mocker):
13 | # given
14 | ROOM_NUMBER = "ROOM-A"
15 | GUEST_MOBILE = "+82-10-1111-2222"
16 | GUEST_NAME = "Guido"
17 |
18 | new_reservation = Reservation(
19 | room=Room(
20 | number=ROOM_NUMBER,
21 | room_status=RoomStatus.AVAILABLE,
22 | ),
23 | reservation_number=ReservationNumber.generate(),
24 | reservation_status=ReservationStatus.IN_PROGRESS,
25 | date_in=datetime(2023, 4, 1),
26 | date_out=datetime(2023, 4, 2),
27 | guest=Guest(
28 | mobile=GUEST_MOBILE,
29 | name=GUEST_NAME,
30 | ),
31 | )
32 |
33 | reservation_cmd = mocker.MagicMock()
34 | reservation_cmd.make_reservation.return_value = new_reservation
35 | with client.app.container.reception.reservation_command.override(reservation_cmd):
36 | # when
37 | response = client.post(
38 | "/reception/reservations",
39 | json={
40 | "room_number": ROOM_NUMBER,
41 | "date_in": "2023-04-01T00:00:00",
42 | "date_out": "2023-04-02T00:00:00",
43 | "guest_mobile": GUEST_MOBILE,
44 | "guest_name": GUEST_NAME,
45 | }
46 | )
47 |
48 | # then
49 | schema = Schema(
50 | {
51 | "detail": "ok",
52 | "result": {
53 | "room": {
54 | "number": ROOM_NUMBER,
55 | "status": RoomStatus.AVAILABLE,
56 | },
57 | "reservation_number": new_reservation.reservation_number.value,
58 | "status": ReservationStatus.IN_PROGRESS,
59 | "date_in": "2023-04-01T00:00:00",
60 | "date_out": "2023-04-02T00:00:00",
61 | "guest": {
62 | "mobile": GUEST_MOBILE,
63 | "name": GUEST_NAME,
64 | }
65 | }
66 | }
67 | )
68 | assert response.status_code == 201
69 | assert schema.validate(response.json())
70 |
71 |
72 | # get reservation
73 |
74 | # update guest info
75 |
76 | # check in
77 |
78 | # check out
79 |
80 | # cancel
--------------------------------------------------------------------------------
/src/tests/integration/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/tests/integration/__init__.py
--------------------------------------------------------------------------------
/src/tests/integration/database/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/tests/integration/database/__init__.py
--------------------------------------------------------------------------------
/src/tests/integration/database/conftest.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from sqlalchemy import create_engine, event
3 | from sqlalchemy.orm import sessionmaker
4 | from sqlalchemy_utils import database_exists, create_database
5 |
6 | from shared_kernel.infra.database.orm import metadata
7 |
8 |
9 | @pytest.fixture(scope="session")
10 | def test_db():
11 | test_db_url = "mysql+pymysql://admin:ddd-hotel@127.0.0.1:3306/ddd-hotel"
12 | if not database_exists(test_db_url):
13 | create_database(test_db_url)
14 |
15 | engine = create_engine(test_db_url)
16 | metadata.create_all(engine)
17 | try:
18 | yield engine
19 | finally:
20 | metadata.drop_all(engine)
21 |
22 |
23 | @pytest.fixture(scope="function")
24 | def test_session(test_db):
25 | connection = test_db.connect()
26 |
27 | trans = connection.begin()
28 | session = sessionmaker()(bind=connection)
29 |
30 | session.begin_nested() # SAVEPOINT
31 |
32 | @event.listens_for(session, "after_transaction_end")
33 | def restart_savepoint(session, transaction):
34 | """
35 | Each time that SAVEPOINT ends, reopen it
36 | """
37 | if transaction.nested and not transaction._parent.nested:
38 | session.begin_nested()
39 |
40 | yield session
41 |
42 | session.close()
43 | trans.rollback() # roll back to the SAVEPOINT
44 | connection.close()
45 |
46 |
47 | @pytest.fixture
48 | def room_display(test_session):
49 | from display.domain.entity.room import Room
50 | from shared_kernel.domain.value_object import RoomStatus
51 |
52 | room = Room(number="New", room_status=RoomStatus.AVAILABLE, image_url="image_url")
53 | test_session.add(room)
54 | test_session.commit()
55 | return room
56 |
--------------------------------------------------------------------------------
/src/tests/integration/database/test_display.py:
--------------------------------------------------------------------------------
1 | from display.domain.entity.room import Room
2 | from shared_kernel.domain.value_object import RoomStatus
3 |
4 |
5 | def test_save_room(test_session):
6 | # given
7 | assert not test_session.query(Room).first()
8 |
9 | new_room = Room(number="New", room_status=RoomStatus.AVAILABLE, image_url="image_url")
10 |
11 | # when
12 | test_session.add(new_room)
13 | test_session.commit()
14 |
15 | # then
16 | assert test_session.query(Room).filter(
17 | Room.number == "New",
18 | Room.room_status == RoomStatus.AVAILABLE,
19 | Room.image_url == "image_url",
20 | ).first()
21 |
--------------------------------------------------------------------------------
/src/tests/integration/database/test_reception.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from reception.domain.entity.reservation import Reservation
4 | from reception.domain.entity.room import Room
5 | from reception.domain.value_object.guest import Guest
6 |
7 |
8 | def test_make_reservation(test_session, room_display):
9 | # given
10 | room = test_session.query(Room).first()
11 |
12 | # when
13 | new_reservation = Reservation.make(
14 | room=room,
15 | date_in=datetime.datetime(2023, 4, 1),
16 | date_out=datetime.datetime(2023, 4, 2),
17 | guest=Guest(
18 | mobile="+82-10-1111-2222",
19 | name="Guido",
20 | )
21 | )
22 |
23 | test_session.add(new_reservation)
24 | test_session.commit()
25 |
26 | # then
27 | assert test_session.query(Reservation).filter(
28 | Reservation.room == room
29 | ).first()
30 |
--------------------------------------------------------------------------------
/src/tests/unit/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/qu3vipon/python-ddd/8920c4868f03061496c296abb1ffbd8c9db64b20/src/tests/unit/__init__.py
--------------------------------------------------------------------------------