diff --git a/README.md b/README.md index 20dd6c4..b24a32e 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ # Event Sourcing with Django -This package supports using the Python -[eventsourcing](https://github.com/pyeventsourcing/eventsourcing) library -with [Django ORM](https://www.djangoproject.com/). +This package is a Django app that supports using the Python +[eventsourcing](https://github.com/pyeventsourcing/eventsourcing) library with the [Django ORM](https://www.djangoproject.com/). To use Django with your Python eventsourcing applications: * install the Python package `eventsourcing_django` @@ -21,24 +20,13 @@ install Python packages into a Python virtual environment. $ pip install eventsourcing_django +Alternatively, add `eventsourcing_django` to your project's `pyproject.yaml` +or `requirements.txt` file and update your virtual environment accordingly. -## Django +## Event sourcing application -Add `'eventsourcing_django'` to your Django project's `INSTALLED_APPS` setting. - - INSTALLED_APPS = [ - ... - 'eventsourcing_django', - ] - -Run Django's `manage.py migrate` command to create database tables for storing events. - - $ python manage.py migrate eventsourcing_django - - -## Event sourcing - -Define aggregates and applications in the usual way. +Define event-sourced aggregates and applications using the `Application` and +`Aggregate` classes from the `eventsourcing` package. ```python from eventsourcing.application import Application @@ -75,63 +63,193 @@ class Dog(Aggregate): def add_trick(self, trick): self.tricks.append(trick) ``` -Construct and use the application in the usual way. -Set `PERSISTENCE_MODULE` to `'eventsourcing_django'` -in the application's environment. -You may wish to construct the application object on a signal -when the Django project is "ready". You can use the `ready()` -method of the `AppConfig` class in the `apps.py` module of a -Django app. If you migrate before including the TrainingSchool object into your code, -this way should work fine in development: + +The event sourcing application can be developed and tested independently of Django. + +Next, let's configure a Django project, and our event sourcing application, so +that events of the event sourcing application are stored in a Django database. + +## Django project settings + +Add `'eventsourcing_django'` to your Django project's `INSTALLED_APPS` setting. + + INSTALLED_APPS = [ + ... + 'eventsourcing_django', + ] + +This will make the Django models for storing events available in your Django project, +and allow Django to create tables in your database for storing events. + +## Django database migration + +Run Django's `manage.py migrate` command to create database tables for storing events. + + $ python manage.py migrate + +Use the `--database` option to create tables in a non-default database. The database +alias must be a key in the `DATABASES` setting of your Django project. + + $ python manage.py migrate --database=postgres + +Alternatively, after the Django framework has been set up for your project, you +can call Django's `call_command()` function to create the database tables. + ```python -school = TrainingSchool(env={ - "PERSISTENCE_MODULE": "eventsourcing_django", -}) +from django.core.management import call_command + +call_command("migrate") +``` + +Use the `database` keyword argument to create tables in a non-default database. + +```python +call_command("migrate", database="postgres") +``` + +To set up the Django framework for your Django project, `django.setup()` must have +been called after setting environment variable `DJANGO_SETTINGS_MODULE` to indicate the +settings module of your Django project. This is often done by a Django project's +`manage.py`, `wsgi.py`, and `wsgi.py` files, and by tools that support Django users +such as test suite runners provided by IDEs that support Django. Django test suites +usually automatically create and migrate databases when tests are run. + +## Event sourcing in Django + +The event sourcing application can be configured to store events in the Django project's +database by setting the environment variable `'PERSISTENCE_MODULE'` to +`'eventsourcing_django'`. This step also depends on the Django framework having been +set up to for your Django project, but it doesn't depend on the database tables having +been created. + +```python +training_school = TrainingSchool( + env={"PERSISTENCE_MODULE": "eventsourcing_django"}, +) +``` + +Use the application environment variable `'DJANGO_DB_ALIAS'` to configure the application +to store events in a non-default Django project database. The value of `'DJANGO_DB_ALIAS'` +must correspond to one of the keys in the `DATABASES` setting of the Django project. + +```python +training_school = TrainingSchool( + env={ + "PERSISTENCE_MODULE": "eventsourcing_django", + "DJANGO_DB_ALIAS": "postgres", + } +) ``` -But usually you need migrations to run before creating the objects from database data -and also put the created object into django app config: +You may wish to define your event sourcing application in a separate Django app, +and construct your event sourcing application in a Django `AppConfig` subclass +in its `apps.py` module. + ```python +# In your apps.py file. +from django.apps import AppConfig +from django.core.management import call_command + class TrainingSchoolConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = ".training_school" + name = ".training_school" def ready(self): call_command("migrate") self.create_training_school() def create_training_school(self): - training_school = TrainingSchool( + self.training_school = TrainingSchool( env={"PERSISTENCE_MODULE": "eventsourcing_django"} ) - apps.get_app_config("training_school").training_school = training_school + +``` + +You may also wish to centralize the definition of your event sourcing application's +environment variables in your Django project's settings module, and use this when +constructing the event sourcing application. + +```python +# Create secret cipher key. +import os +from eventsourcing.cipher import AESCipher +os.environ["CIPHER_KEY"] = AESCipher.create_key(32) + +# In your settings.py file. +import os + +EVENT_SOURCING_APPLICATION = { + "PERSISTENCE_MODULE": "eventsourcing_django", + "DJANGO_DB_ALIAS": "postgres", + "IS_SNAPSHOTTING_ENABLED": "y", + "COMPRESSOR_TOPIC": "eventsourcing.compressor:ZlibCompressor", + "CIPHER_TOPIC": "eventsourcing.cipher:AESCipher", + "CIPHER_KEY": os.environ["CIPHER_KEY"], +} + +# In your apps.py file. +from django.apps import AppConfig +from django.conf import settings + +class TrainingSchoolConfig(AppConfig): + name = ".training_school" + + def ready(self): + self.training_school = TrainingSchool(env=settings.EVENT_SOURCING_APPLICATION) ``` -And then use it like: +The single instance of the event sourcing application can then be obtained in other +places, such as views, forms, management commands, and tests. + ```python -school = apps.get_app_config("training_school").training_school +from django.apps import apps + +training_school = apps.get_app_config("training_school").training_school ``` -The application's methods may be called from Django views and forms. +The event sourcing application's methods can be called in views, forms, +management commands, and tests. ```python -school.register('Fido') -school.add_trick('Fido', 'roll over') -school.add_trick('Fido', 'play dead') -tricks = school.get_tricks('Fido') +training_school.register('Fido') + +training_school.add_trick('Fido', 'roll over') +training_school.add_trick('Fido', 'play dead') + +tricks = training_school.get_tricks('Fido') assert tricks == ['roll over', 'play dead'] ``` +Events will be stored in the Django project's database, so long as the +database tables have been created before the event sourcing application +methods are called. If the database tables have not been created, an +`OperationalError` will be raised to indicate that the tables are not found. + +## Summary + +In summary, before constructing an event sourcing application with `eventsourcing_django` +as its persistence module, the Django framework must have been set up for a Django +project that has `'eventsourcing_django'` included in its `INSTALLED_APPS` setting. +And, before calling the methods of the event sourcing application, the Django project's +database must have been migrated. + For more information, please refer to the Python [eventsourcing](https://github.com/johnbywater/eventsourcing) library and the [Django](https://www.djangoproject.com/) project. -## Management Commands +## Management commands + +The `eventsourcing_django` package is a Django app which ships with the following +Django management commands. They are available in Django projects that have +`'eventsourcing_django'` included in their `INSTALLED_APPS` setting. + +At the moment, there is only one management command: `sync_followers`. -The Django app `eventsourcing_django` ships with the following management commands. +The `sync_followers` management command helps users of the `eventsourcing.system` +module. Please refer to the `eventsourcing` package docs for more information +about the `eventsourcing.system` module. -### Synchronise Followers +### Synchronise followers Manually synchronise followers (i.e. `ProcessApplication` instances) with all of their leaders, as defined in the `eventsourcing.system.System`'s pipes. diff --git a/eventsourcing_django/__init__.py b/eventsourcing_django/__init__.py index e69de29..00b1c15 100644 --- a/eventsourcing_django/__init__.py +++ b/eventsourcing_django/__init__.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +from typing import Any + +from eventsourcing.persistence import ( + AggregateRecorder, + ApplicationRecorder, + InfrastructureFactory, + OperationalError, + ProcessRecorder, +) + + +class Factory(InfrastructureFactory): + def __init__(self, **kwargs: Any) -> None: + msg = ( + "Django app not ready. Please call django.setup() after setting " + "environment variable DJANGO_SETTINGS_MODULE to the settings module of a " + "Django project that has 'eventsourcing_django' included in its " + "INSTALLED_APPS setting, and ensure the Django project's database has been " + "migrated before calling the methods of your event sourcing application." + ) + raise OperationalError(msg) + + def aggregate_recorder(self, purpose: str = "events") -> AggregateRecorder: + raise NotImplementedError() + + def application_recorder(self) -> ApplicationRecorder: + raise NotImplementedError() + + def process_recorder(self) -> ProcessRecorder: + raise NotImplementedError() diff --git a/eventsourcing_django/recorders.py b/eventsourcing_django/recorders.py index 06a201e..7959f72 100644 --- a/eventsourcing_django/recorders.py +++ b/eventsourcing_django/recorders.py @@ -72,23 +72,23 @@ def _wrapper(*args: Any, **kwargs: Any) -> Any: try: return f(*args, **kwargs) except django.db.InterfaceError as e: - raise InterfaceError from e + raise InterfaceError(e) from e except django.db.DataError as e: - raise DataError from e + raise DataError(e) from e except django.db.OperationalError as e: - raise OperationalError from e + raise OperationalError(e) from e except django.db.IntegrityError as e: - raise IntegrityError from e + raise IntegrityError(e) from e except django.db.InternalError as e: - raise InternalError from e + raise InternalError(e) from e except django.db.ProgrammingError as e: - raise ProgrammingError from e + raise ProgrammingError(e) from e except django.db.NotSupportedError as e: - raise NotSupportedError from e + raise NotSupportedError(e) from e except django.db.DatabaseError as e: - raise DatabaseError from e + raise DatabaseError(e) from e except django.db.Error as e: - raise PersistenceError from e + raise PersistenceError(e) from e return _wrapper diff --git a/poetry.lock b/poetry.lock index 373a274..3428a7c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -434,13 +434,13 @@ bcrypt = ["bcrypt"] [[package]] name = "eventsourcing" -version = "9.3.4" +version = "9.3.5" description = "Event sourcing in Python" optional = false python-versions = "<4.0,>=3.8" files = [ - {file = "eventsourcing-9.3.4-py3-none-any.whl", hash = "sha256:e27d7ade8ebea1738f11f52ffc84355a8f09ce88e55b05d06a0255bedd44f9c3"}, - {file = "eventsourcing-9.3.4.tar.gz", hash = "sha256:2c75560ed0325093f0c05fd1881987dcd8854b23cb4ea8b1dfad7ae43297bca8"}, + {file = "eventsourcing-9.3.5-py3-none-any.whl", hash = "sha256:7debbf1fbf5381cd3b072356a00fd21d6bd29ccf526412e3ca061cfe3068a92b"}, + {file = "eventsourcing-9.3.5.tar.gz", hash = "sha256:30ba7cc28c409abff0005967e9053f2f81334acb796793806444ed081f95a4f6"}, ] [package.dependencies] @@ -1359,4 +1359,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "38e27aa95bc0a8e9a78d2e093cc5707a7636e6069f20cae6a2e1e24487306edd" +content-hash = "831efda271f93d57acedf4f3d7283ccc0a3f3b140483606bd064e7d9bc625c73" diff --git a/pyproject.toml b/pyproject.toml index 611d062..c17eb6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ include = ["eventsourcing_django/py.typed"] [tool.poetry.dependencies] python = "^3.8" -eventsourcing = { version = "~9.3" } +eventsourcing = { version = ">=9.3.5,<9.4" } #eventsourcing = { path = "../eventsourcing/", extras = ["crypto"], develop = true } #eventsourcing = { git = "https://github.com/pyeventsourcing/eventsourcing.git", branch = "main", extras = ["crypto"]} diff --git a/tests/djangoproject/settings.py b/tests/djangoproject/settings.py index 1b9c056..56b6d19 100644 --- a/tests/djangoproject/settings.py +++ b/tests/djangoproject/settings.py @@ -45,7 +45,7 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - "eventsourcing_django.apps.EventsourcingConfig", + "eventsourcing_django", "tests.eventsourcing_runner_django.apps.EventSourcingSystemRunnerConfig", ] @@ -111,7 +111,7 @@ "ENGINE": "django.db.backends.postgresql_psycopg2", "NAME": os.getenv("POSTGRES_DB", "eventsourcing_django"), "TEST": { - "NAME": "test_" + os.getenv("POSTGRES_DB", "eventsourcing_django"), + "NAME": os.getenv("POSTGRES_DB", "eventsourcing_django"), }, "USER": os.getenv("POSTGRES_USER", "eventsourcing"), "PASSWORD": os.getenv("POSTGRES_PASSWORD", "eventsourcing"), diff --git a/tests/djangoproject/settings_testdocs.py b/tests/djangoproject/settings_testdocs.py new file mode 100644 index 0000000..1b5d023 --- /dev/null +++ b/tests/djangoproject/settings_testdocs.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +from eventsourcing.cipher import AESCipher + +from tests.djangoproject.settings import * # noqa: F403 + +INSTALLED_APPS = INSTALLED_APPS + ["tests.training_school"] # noqa: F405 + +EVENT_SOURCING_SETTINGS = EVENT_SOURCING_APPLICATION = { + "PERSISTENCE_MODULE": "eventsourcing_django", + "DJANGO_DB_ALIAS": "postgres", + "IS_SNAPSHOTTING_ENABLED": "y", + "COMPRESSOR_TOPIC": "eventsourcing.compressor:ZlibCompressor", + "CIPHER_TOPIC": "eventsourcing.cipher:AESCipher", + "CIPHER_KEY": AESCipher.create_key(num_bytes=32), +} diff --git a/tests/test_docs.py b/tests/test_docs.py index e70879f..120b05f 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -2,7 +2,9 @@ from __future__ import annotations import os +import sys from pathlib import Path +from subprocess import PIPE, Popen from tempfile import NamedTemporaryFile from tests.test_recorders import DjangoTestCase @@ -11,11 +13,16 @@ class TestDocs(DjangoTestCase): + databases = {"default", "postgres"} + def setUp(self) -> None: super().setUp() + self.original_django_settings_module = os.environ["DJANGO_SETTINGS_MODULE"] + os.environ["DJANGO_SETTINGS_MODULE"] = "tests.djangoproject.settings_testdocs" def tearDown(self) -> None: self.clean_env() + os.environ["DJANGO_SETTINGS_MODULE"] = self.original_django_settings_module def clean_env(self) -> None: keys = [ @@ -133,39 +140,48 @@ def check_code_snippets_in_file(self, doc_path: Path) -> None: # noqa: C901 print(f"{num_code_lines} lines of code in {doc_path}") + self.assertEqual("", lines[0]) + self.assertEqual("", lines[1]) + lines[0] = "import django" + lines[1] = "django.setup()" + # Write the code into a temp file. tempfile = NamedTemporaryFile("w+") source = "\n".join(lines) + "\n" tempfile.writelines(source) tempfile.flush() - exec( - compile(source=source, filename=doc_path, mode="exec"), globals(), globals() - ) - return + # exec( + # compile(source=source, filename=doc_path, mode="exec"), globals(), globals() + # ) + # return # print(Path.cwd()) # print("\n".join(lines) + "\n") # - # # Run the code and catch errors. - # p = Popen( - # [sys.executable, temp_path], - # stdout=PIPE, - # stderr=PIPE, - # env={"PYTHONPATH": BASE_DIR}, - # ) - # print(sys.executable, temp_path, PIPE) - # out, err = p.communicate() - # decoded_out = out.decode("utf8").replace(temp_path, str(doc_path)) - # decoded_err = err.decode("utf8").replace(temp_path, str(doc_path)) - # exit_status = p.wait() - # - # print(decoded_out) - # print(decoded_err) - # - # # Check for errors running the code. - # if exit_status: - # self.fail(decoded_out + decoded_err) - # - # # Close (deletes) the tempfile. - # tempfile.close() + # Run the code and catch errors. + # - need to run this in a subprocess so we can django.setup() with alternative settings + env = os.environ.copy() + env["PYTHONPATH"] = str(BASE_DIR) + p = Popen( + [sys.executable, tempfile.name], + stdout=PIPE, + stderr=PIPE, + env=env, + # cwd=BASE_DIR, + ) + print(sys.executable, tempfile.name, PIPE) + out, err = p.communicate() + decoded_out = out.decode("utf8").replace(tempfile.name, str(doc_path)) + decoded_err = err.decode("utf8").replace(tempfile.name, str(doc_path)) + exit_status = p.wait() + + print(decoded_out) + print(decoded_err) + + # Check for errors running the code. + if exit_status: + self.fail(decoded_out + decoded_err) + + # Close (deletes) the tempfile. + tempfile.close() diff --git a/tests/test_recorders.py b/tests/test_recorders.py index 4a60e99..8e644bf 100644 --- a/tests/test_recorders.py +++ b/tests/test_recorders.py @@ -97,6 +97,9 @@ def close_db_connection(self, *args: Any) -> None: class TestDjangoApplicationRecorderWithSQLiteInMemory(TestDjangoApplicationRecorder): + # db_alias = "default" + # databases = {"default"} + @skip(reason="Get 'Database is locked' error with GitHub Actions") def test_concurrent_no_conflicts(self) -> None: super().test_concurrent_no_conflicts() @@ -104,12 +107,11 @@ def test_concurrent_no_conflicts(self) -> None: class TestDjangoApplicationRecorderWithSQLiteFileDb(TestDjangoApplicationRecorder): db_alias = "sqlite_filedb" - databases = {"default", "sqlite_filedb"} + databases = {"sqlite_filedb"} class TestDjangoApplicationRecorderWithPostgres(TestDjangoApplicationRecorder): db_alias = "postgres" - # databases = {"default", "postgres"} databases = {"postgres"} # @classmethod diff --git a/tests/training_school/__init__.py b/tests/training_school/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/training_school/application.py b/tests/training_school/application.py new file mode 100644 index 0000000..23d4131 --- /dev/null +++ b/tests/training_school/application.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +from typing import List + +from eventsourcing.application import Application + +from tests.training_school.domain import Dog + + +class TrainingSchool(Application): + def register(self, name: str) -> None: + dog = Dog(name) + self.save(dog) + + def add_trick(self, name: str, trick: str) -> None: + dog: Dog = self.repository.get(Dog.create_id(name)) + dog.add_trick(trick) + self.save(dog) + + def get_tricks(self, name: str) -> List[str]: + dog: Dog = self.repository.get(Dog.create_id(name)) + return dog.tricks diff --git a/tests/training_school/apps.py b/tests/training_school/apps.py new file mode 100644 index 0000000..ed3a1b9 --- /dev/null +++ b/tests/training_school/apps.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +from django.apps import AppConfig +from django.conf import settings + +from tests.training_school.application import TrainingSchool + + +class TrainingSchoolConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "tests.training_school" + + def ready(self) -> None: + self.training_school = TrainingSchool(env=settings.EVENT_SOURCING_SETTINGS) diff --git a/tests/training_school/domain.py b/tests/training_school/domain.py new file mode 100644 index 0000000..0c14c26 --- /dev/null +++ b/tests/training_school/domain.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from typing import List +from uuid import NAMESPACE_URL, UUID, uuid5 + +from eventsourcing.domain import Aggregate, event + + +class Dog(Aggregate): + @event("Registered") + def __init__(self, name: str) -> None: + self.name = name + self.tricks: List[str] = [] + + @staticmethod + def create_id(name: str) -> UUID: + return uuid5(NAMESPACE_URL, f"/dogs/{name}") + + @event("TrickAdded") + def add_trick(self, trick: str) -> None: + self.tricks.append(trick)