├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── conftest.py ├── djangoevents ├── __init__.py ├── app.py ├── apps.py ├── domain.py ├── exceptions.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── validate_event_schemas.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_event_event_version.py │ ├── 0003_auto_20171220_0404.py │ └── __init__.py ├── models.py ├── repository.py ├── schema.py ├── settings.py ├── shortcuts.py ├── tests │ ├── __init__.py │ ├── settings │ │ ├── __init__.py │ │ ├── settings_test.py │ │ └── settings_test_local.py │ ├── test_domain.py │ ├── test_management.py │ ├── test_repo.py │ ├── test_schema.py │ ├── test_shortcuts.py │ ├── test_store.py │ ├── test_unifiedtranscoder.py │ ├── test_utils.py │ └── test_utils_abstract.py ├── unifiedtranscoder.py ├── utils.py └── utils_abstract.py ├── requirements.txt ├── scripts └── makemigrations.sh ├── setup.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | dist 3 | .tox/ 4 | *.sqlite3 5 | /.idea/ 6 | .cache/ 7 | __pycache__ 8 | *.egg-info 9 | /.cache/ 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | 4 | python: 5 | - "3.5" 6 | - "3.6" 7 | install: pip install tox-travis 8 | script: tox 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 0.14.1 2 | ====== 3 | 4 | - Change the way modules are autodiscovered (use Django utility). 5 | It should work now with new style apps. 6 | 7 | 8 | 0.14.0 9 | ====== 10 | 11 | - Added index on the `stored_entity_id` field which fixes performance 12 | issue when there are many events. It requires migration, version 13 | change reflects that. 14 | 15 | 16 | 0.13.4 17 | ====== 18 | 19 | - Switched to `schema_version = None` default when deserializing 20 | events without `schema_version`. 21 | 22 | 23 | 0.13.3 24 | ====== 25 | 26 | - Stopped overriding `schema_version` if it exists. 27 | - Started passing all event attributes to event constructor during 28 | deserialization. This allows to handle event structure migration 29 | ("upcasting") in each event's constructor: one can modify `kwargs` 30 | before calling `super().__init__`. 31 | 32 | 33 | 0.13.2 34 | ====== 35 | 36 | - Made `@abstract` non inheritable: now only classes explicitly marked 37 | with the `@abstract` decorator are abstract. Their subclasses are not. 38 | This was always the intended behavior. 39 | 40 | 41 | 0.13.1 42 | ====== 43 | 44 | - Switched to adding `schema_version` to event objects themselves 45 | instead of just transcoded events. This way events will pass 46 | validation if their Avro schemas require `schema_version`. 47 | 48 | - Renamed setting responsible for the above feature 49 | from `EVENT_TRANSCODER.ADDS_EVENT_VERSION_TO_DATA` 50 | to `ADDS_SCHEMA_VERSION_TO_EVENT_DATA`. 51 | 52 | 53 | 0.13.0 54 | ====== 55 | 56 | - Renamed `Event.schema_version` to `Event.version`. 57 | - Started setting `Event.version` based on schema file with the highest number. 58 | - Added event version to stored event envelope under the `event_version` key. 59 | - Added setting to add event version to event data as well (see "Event version" section in README). 60 | 61 | 62 | 0.12.0 63 | ====== 64 | 65 | - Started automatically choosing Avro schemas with the highest version 66 | (based on file names on disk). 67 | 68 | - Changed way how event type string is computed. For example, 69 | given User aggregate and Created event, now it would be "user_created" 70 | (while in older versions that would be simply "Created" which sometimes 71 | is not sufficient enough). Moreover it is now possible to override that 72 | with `event_type` attribute set on an event class. 73 | 74 | 0.9.4 75 | ===== 76 | - Made `DomainEvent.metadata` optional during serialization ([PR #10](https://github.com/ApplauseOSS/djangoevents/pull/10)). 77 | 78 | 0.9.3 79 | ===== 80 | - Fixed exception when DomainEvent.metadata is not defined on saving to event store. [PR#7](https://github.com/ApplauseOSS/djangoevents/pull/7) 81 | 82 | 0.9.2 83 | ===== 84 | 85 | - When attempt to create an aggregate using ID of existing one, 86 | `esdjango.exceptions.AlreadyExists` error would be raised. 87 | NOTE: This is possibly a breaking change: previously Django's 88 | standard IntegrityError was raised. 89 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to djangoevents 2 | 3 | Please help make djangoevents better. 4 | 5 | When contributing to this repository, please first discuss the change you wish to make via issue, 6 | email, or any other method with the owners of this repository before making a change. 7 | 8 | Please note we have a code of conduct, please follow it in all your interactions with the project. 9 | 10 | ## Pull Request Process 11 | 12 | 1. Have full test coverage on the new or changed code. 13 | 2. Update the README.md with details of changes to any interface. 14 | 3. Ensure all build and tests passes before issue the pull request. 15 | 4. Please add meaningful description for the proposed change/fix on the pull request description. 16 | 5. Have at least one reviewer approve the pull request. 17 | 6. You may request the repo maintainer to merge the pull request for you. 18 | 7. Bugfixes should target master branch while new features should target devel branch. 19 | 20 | ## Code of Conduct 21 | 22 | ### Our Pledge 23 | 24 | In the interest of fostering an open and welcoming environment, we as 25 | contributors and maintainers pledge to making participation in our project and 26 | our community a harassment-free experience for everyone, regardless of age, body 27 | size, disability, ethnicity, gender identity and expression, level of experience, 28 | nationality, personal appearance, race, religion, or sexual identity and 29 | orientation. 30 | 31 | ### Our Standards 32 | 33 | Examples of behavior that contributes to creating a positive environment 34 | include: 35 | 36 | * Using welcoming and inclusive language 37 | * Being respectful of differing viewpoints and experiences 38 | * Gracefully accepting constructive criticism 39 | * Focusing on what is best for the community 40 | * Showing empathy towards other community members 41 | 42 | Examples of unacceptable behavior by participants include: 43 | 44 | * The use of sexualized language or imagery and unwelcome sexual attention or 45 | advances 46 | * Trolling, insulting/derogatory comments, and personal or political attacks 47 | * Public or private harassment 48 | * Publishing others' private information, such as a physical or electronic 49 | address, without explicit permission 50 | * Other conduct which could reasonably be considered inappropriate in a 51 | professional setting 52 | 53 | ### Our Responsibilities 54 | 55 | Project maintainers are responsible for clarifying the standards of acceptable 56 | behavior and are expected to take appropriate and fair corrective action in 57 | response to any instances of unacceptable behavior. 58 | 59 | Project maintainers have the right and responsibility to remove, edit, or 60 | reject comments, commits, code, wiki edits, issues, and other contributions 61 | that are not aligned to this Code of Conduct, or to ban temporarily or 62 | permanently any contributor for other behaviors that they deem inappropriate, 63 | threatening, offensive, or harmful. 64 | 65 | ### Scope 66 | 67 | This Code of Conduct applies both within project spaces and in public spaces 68 | when an individual is representing the project or its community. Examples of 69 | representing a project or community include using an official project e-mail 70 | address, posting via an official social media account, or acting as an appointed 71 | representative at an online or offline event. Representation of a project may be 72 | further defined and clarified by project maintainers. 73 | 74 | ### Enforcement 75 | 76 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 77 | reported by contacting the project team at eng@applause.com. All 78 | complaints will be reviewed and investigated and will result in a response that 79 | is deemed necessary and appropriate to the circumstances. The project team is 80 | obligated to maintain confidentiality with regard to the reporter of an incident. 81 | Further details of specific enforcement policies may be posted separately. 82 | 83 | Project maintainers who do not follow or enforce the Code of Conduct in good 84 | faith may face temporary or permanent repercussions as determined by other 85 | members of the project's leadership. 86 | 87 | ### Attribution 88 | 89 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 90 | available at [http://contributor-covenant.org/version/1/4][version] 91 | 92 | [homepage]: http://contributor-covenant.org 93 | [version]: http://contributor-covenant.org/version/1/4/ 94 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Applause 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | include README.md 3 | include *.py 4 | include *.ini 5 | include *.txt 6 | recursive-include djangoevents *.py 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PYTHON ?= python3 2 | 3 | export VIRTUAL_ENV := $(realpath .)/venv 4 | export PATH := $(VIRTUAL_ENV)/bin:$(PATH) 5 | unexport WORKON_HOME PIP_RESPECT_VIRTUALENV PIP_VIRTUALENV_BASE 6 | 7 | 8 | # Configure THIS: 9 | PROJECT_FOLDER=./djangoevents 10 | PROJECT_NAME=djangoevents 11 | 12 | help: 13 | @echo 14 | @echo 'Make targets:' 15 | @echo ' make install' 16 | @echo ' -> make install.virtualenv' 17 | @echo ' -> make install.runtime' 18 | @echo ' -> make install.package' 19 | @echo ' make clean' 20 | @echo ' make test' 21 | @echo ' make tdd' 22 | 23 | 24 | # Top-level phony targets 25 | _install__runtime = $(VIRTUAL_ENV)/bin/django-admin.py 26 | _install__virtualenv = $(VIRTUAL_ENV)/bin/pip 27 | _install__package = ./djangoevents.egg-info 28 | 29 | # make should not confuse these commands with files 30 | .PHONY: install install.virtualenv install.runtime install.package 31 | .PHONY: clean 32 | 33 | clean: 34 | rm -rf $(VIRTUAL_ENV) 35 | find . -type f -name '*.pyc' -print0 | xargs -0 rm -f 36 | find . -type d -name '__pycache__' -print0 | xargs -0 rm -rf 37 | rm -rf $(PROJECT_FOLDER).egg-info 38 | 39 | # Installation 40 | install: install.virtualenv install.runtime install.package 41 | install.runtime: $(_install__runtime) 42 | install.virtualenv: $(_install__virtualenv) 43 | install.package: $(_install__package) 44 | 45 | $(_install__runtime): 46 | $(_install__virtualenv) install -r requirements.txt 47 | touch $@ 48 | 49 | $(_install__package): 50 | @echo '================================================================' 51 | @echo 'About to install $(PROJECT_NAME) in development mode' 52 | @echo 'Package would be linked within virtualenv so there is no need' 53 | @echo 'to reinstall it every time source code is changed' 54 | @echo '================================================================' 55 | $(VIRTUAL_ENV)/bin/python setup.py develop 56 | touch $@ 57 | 58 | $(_install__virtualenv): 59 | $(PYTHON) -mvenv $(VIRTUAL_ENV) 60 | @echo '================================================================' 61 | @echo 'You can now enable virtualenv with:' 62 | @echo ' source $(VIRTUAL_ENV)/bin/activate' 63 | @echo '================================================================' 64 | $(VIRTUAL_ENV)/bin/pip install -U pip 65 | touch $@ 66 | 67 | 68 | test: 69 | $(VIRTUAL_ENV)/bin/py.test ${PROJECT_FOLDER} 70 | 71 | tdd: 72 | $(VIRTUAL_ENV)/bin/ptw -c -- ${PROJECT_FOLDER} 73 | 74 | release: 75 | # Warning: requires setting PYPI_USERNAME and PYPI_PASSWORD environment variables 76 | # in order to authenticate to PyPI 77 | $(PYTHON) setup.py sdist release_to_pypi 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to djangoevents' documentation! 2 | djangoevents offers building blocks for building Event Sourcing Django applications. 3 | 4 | [![Build Status](https://travis-ci.org/ApplauseOSS/djangoevents.svg?branch=master)](https://travis-ci.org/ApplauseOSS/djangoevents) 5 | [![Build Status](https://travis-ci.org/ApplauseOSS/djangoevents.svg?branch=devel)](https://travis-ci.org/ApplauseOSS/djangoevents) 6 | 7 | ## Setup 8 | Install with pip: 9 | 10 | ``` 11 | pip install djangoevents 12 | ``` 13 | 14 | Include in settings.py: 15 | ```python 16 | INSTALLED_APPS = [ 17 | ... 18 | 'djangoevents', 19 | ... 20 | ] 21 | ``` 22 | 23 | ## Event Sourcing Components 24 | djangoevents takes advantage of [eventsourcing](https://github.com/johnbywater/eventsourcing) library for handling event sourcing and replaces its storage backend with Django Model for seamless integration with Django. 25 | 26 | ### BaseAggregate 27 | It is required to place all aggregate definitions in `aggregates.py` module of a Django application. 28 | 29 | BaseEntity is a wrapper over EventSourcedEntity from eventsourcing's EventSourcedEntity. It is used to define Aggregates, its domain events and how domain events apply changes to Aggregates in one place. 30 | 31 | ```python 32 | from djangoevents import BaseAggregate 33 | 34 | class Todo(BaseAggregate): 35 | 36 | class Created(BaseAggregate.Created): 37 | def mutate_event(self, event, cls): 38 | return cls( 39 | entity_id=event.entity_id, 40 | entity_version=event.entity_version, 41 | domain_event_id=event.domain_event_id, 42 | label=event.label, 43 | done=False, 44 | ) 45 | 46 | class ChangedLabel(BaseAggregate.AttributeChanged): 47 | def mutate_event(self, event, instance): 48 | instance.label = event.label 49 | return instance 50 | ``` 51 | 52 | 53 | ### EventSourcingWithDjango 54 | For seamless integration with Django, we created an implementation of eventsourcing's eventStore using Django ORM and built EventSourcingWithDjango on top of it. By using EventSourcingWithDjango, the Django ORM will be used to store events. Here is a short example of how to create and save an event: 55 | ```python 56 | from djangoevents import EventSourcingWithDjango 57 | from djangoevents import store_event 58 | 59 | class Todo(EventSourcedEntity): 60 | ... 61 | 62 | class TodoRepository(EventSourcedRepository): 63 | domain_class = Todo 64 | 65 | es_app = EventSourcingWithDjango() 66 | repo = es_app.get_repo_for_entity(Todo) 67 | 68 | # publish event (saves in the database) 69 | todo_created_event = Todo.Created( 70 | entity_id='6deaca4c-d866-4b28-9878-8814a55a4688', 71 | label='my next thing to do', 72 | metadata={'command_id': '...'} 73 | ) 74 | store_event(todo_created_event) 75 | 76 | # get todo aggregate from repo by aggregate id 77 | my_todo = repo['6deaca4c-d866-4b28-9878-8814a55a4688'] 78 | 79 | ``` 80 | 81 | 82 | ### Event handlers 83 | 84 | If extra event handling on event publish other than saving it to event journal is a requirement, add `handlers.py` file in your app and use _subscribe_to_ decorator with the DomainEvent class you intent to listen on. Example: 85 | 86 | 87 | ```python 88 | from djangoevents import subscribe_to 89 | from myapp.entities import Miracle 90 | 91 | @subscribe_to(Miracle.Happened) 92 | def miracle_handler(event): 93 | print(" => miracle happened! update your projections here!") 94 | ``` 95 | 96 | 97 | Note: name of that file is important. There is auto-import mechanism that would import 98 | `handlers.py` file for all apps mentioned in `INSTALLED_APPS`. You can put handler 99 | functions anywhere you like but you'd need to make sure it's imported somehow. 100 | 101 | ### Import shortcuts 102 | We have ported commonly used functionality from [eventsourcing](https://github.com/johnbywater/eventsourcing) to the top level of this library. Please import from `djangoevents` because we might have extended functionality of some classes and functions. 103 | 104 | ```python 105 | from djangoevents import DomainEvent # from eventsourcing.domain.model.entity import DomainEvent 106 | from djangoevents import EventSourcedEntity # from eventsourcing.domain.model.entity import EventSourcedEntity 107 | from djangoevents import entity_mutator # from eventsourcing.domain.model.entity import entity_mutator 108 | from djangoevents import singledispatch # from eventsourcing.domain.model.entity import singledispatch 109 | 110 | from djangoevents import store_event # from eventsourcing.domain.model.events import publish 111 | from djangoevents import subscribe # from eventsourcing.domain.model.events import subscribe 112 | from djangoevents import unsubscribe # from eventsourcing.domain.model.events import unsubscribe 113 | 114 | from djangoevents import subscribe_to # from eventsourcing.domain.model.decorators import subscribe_to 115 | 116 | from djangoevents import EventSourcedRepository # from eventsourcing.infrastructure.event_sourced_repo import EventSourcedRepository 117 | ``` 118 | 119 | ### Documenting event schema 120 | 121 | Event schema validation is disabled by default. To enable it for the whole project please add `DJANGOEVENTS_CONFIG` to project's `settings.py`: 122 | 123 | ```python 124 | DJANGOEVENTS_CONFIG = { 125 | ... 126 | 'EVENT_SCHEMA_VALIDATION': { 127 | 'ENABLED': True, 128 | 'SCHEMA_DIR': 'avro', 129 | }, 130 | ... 131 | } 132 | ``` 133 | 134 | Once this is enabled avro schema definition will be required for all events in the system. 135 | The only exception to this rule are events of abstract aggregates: 136 | 137 | ```python 138 | from djangoevents import BaseAggregate, abstract 139 | 140 | @abstract 141 | class Animal(BaseAggregate): 142 | class Created(BaseAggregate.Created): 143 | def mutate_event(self, event, cls): 144 | return cls( 145 | entity_id=event.entity_id, 146 | entity_version=event.entity_version, 147 | domain_event_id=event.domain_event_id, 148 | label=event.label, 149 | done=False, 150 | ) 151 | 152 | class Dog(Animal): 153 | pass 154 | 155 | ``` 156 | 157 | In the example above `Animal` was defined as an `abstract` aggregate. Its goal is to keep all common implementation in a single place 158 | for child classes. No event specification is required for `Animal.Created` abstract aggregates. It needs to be present for `Dog.Created` though. 159 | 160 | **It is expected that each service will fully document all events emitted through avro schema definitions**. Read more about [avro format specification](https://avro.apache.org/docs/1.7.7/spec.html). 161 | 162 | By default djangoevents assumes event schemas will be placed in `avro` folder located at project's root directory as specifed below: 163 | 164 | ```bash 165 | $ tree project 166 | |- src 167 | |--- manage.py 168 | |--- .. 169 | |-avro 170 | |--- aggragate_name/ 171 | |----- v1_aggregate_name_test_event1.json 172 | |----- v1_aggregate_name_test_event2.json 173 | ... 174 | ``` 175 | 176 | ```bash 177 | $ cat avro/aggragate_name/v1_aggregate_name_test_event1.json 178 | 179 | { 180 | "name": "aggregate_name_test_event1", 181 | "type": "record", 182 | "doc": "Sample Event", 183 | "fields": [ 184 | { 185 | "name": "entity_id", 186 | "type": "string", 187 | "doc": "ID of a the asset." 188 | }, 189 | { 190 | "name": "entity_version", 191 | "type": "long", 192 | "doc": "Aggregate revision" 193 | }, 194 | { 195 | "name": "domain_event_id", 196 | "type": "string", 197 | "doc": "ID of the last modifying event" 198 | }, 199 | ] 200 | } 201 | 202 | ``` 203 | 204 | Once event schema validation is enabled for your services, following changes will apply: 205 | * At startup (`djangoevents.AppConfig.ready()`) schemas of events of all non-abstract aggregates will be loaded, validated & cached. If any error occurs warning message will be printed in the console. 206 | * `store_event()` will validate your event before storing it to the event journal. 207 | 208 | In cases where enabling validation for the whole project is not possible you can enforce schema validation on-demand by adding `force_valdate=True` parameter to `store_event()` call. 209 | 210 | 211 | ### Event version 212 | 213 | `djangoevents` detects the latest version of your event based on Avro schema files it finds. Version is stored as the `.version` property of an event *class*, e.g. `SomeEvent.version`. Version is added to stored event envelope under the `event_version` key. 214 | 215 | For compatibility with some systems, we provide an option to add event version to event data as well (essentially `stored_event.event_data['schema_version'] = stored_event.event_version`). This functionality is disabled by default, but you can enable it in your project's settings: 216 | 217 | ```python 218 | DJANGOEVENTS_CONFIG = { 219 | ... 220 | 'ADDS_SCHEMA_VERSION_TO_EVENT_DATA': True, 221 | ... 222 | } 223 | ``` 224 | 225 | 226 | ## Development 227 | #### Build 228 | $ make install 229 | #### Run tests 230 | $ source venv/bin/activate 231 | $ pytest 232 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | from rest_framework.test import APIRequestFactory 2 | import pytest 3 | 4 | 5 | @pytest.fixture 6 | def arf(): 7 | return APIRequestFactory() 8 | -------------------------------------------------------------------------------- /djangoevents/__init__.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from eventsourcing.domain.model.entity import EventSourcedEntity 4 | from eventsourcing.domain.model.entity import entity_mutator 5 | from eventsourcing.domain.model.entity import singledispatch 6 | from eventsourcing.domain.model.decorators import subscribe_to 7 | from eventsourcing.domain.model.events import publish as es_publish 8 | from eventsourcing.domain.model.events import subscribe 9 | from eventsourcing.domain.model.events import unsubscribe 10 | from eventsourcing.infrastructure.event_sourced_repo import EventSourcedRepository 11 | from .domain import BaseEntity 12 | from .domain import BaseAggregate 13 | from .domain import DomainEvent 14 | from .app import EventSourcingWithDjango 15 | from .exceptions import EventSchemaError 16 | from .schema import validate_event 17 | from .settings import is_validation_enabled 18 | 19 | default_app_config = 'djangoevents.apps.AppConfig' 20 | 21 | __all__ = [ 22 | 'DomainEvent', 23 | 'EventSourcedEntity', 24 | 'EventSourcedRepository', 25 | 'entity_mutator', 26 | 'singledispatch', 27 | 'publish', 28 | 'store_event' 29 | 'subscribe', 30 | 'unsubscribe', 31 | 'subscribe_to', 32 | 'BaseEntity', 33 | 'BaseAggregate', 34 | 'EventSourcingWithDjango' 35 | ] 36 | 37 | 38 | def publish(event): 39 | warnings.warn("`publish` is depreciated. Please switch to: `store_event`.", DeprecationWarning) 40 | return es_publish(event) 41 | 42 | 43 | def store_event(event, force_validate=False): 44 | """ 45 | Store an event to the service's event journal. Optionally validates event 46 | schema if one is provided. 47 | 48 | `force_validate` - enforces event schema validation even if configuration disables it globally. 49 | """ 50 | if is_validation_enabled() or force_validate: 51 | is_valid = validate_event(event) 52 | if not is_valid: 53 | msg = "Event: {} does not match its schema.".format(event) 54 | raise EventSchemaError(msg) 55 | 56 | return es_publish(event) 57 | -------------------------------------------------------------------------------- /djangoevents/app.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from .repository import DjangoStoredEventRepository 4 | from .unifiedtranscoder import UnifiedTranscoder 5 | from django.core.serializers.json import DjangoJSONEncoder 6 | from eventsourcing.application.base import EventSourcingApplication 7 | from eventsourcing.infrastructure.event_sourced_repo import EventSourcedRepository 8 | 9 | 10 | class EventSourcingWithDjango(EventSourcingApplication): 11 | def __init__(self, **kwargs): 12 | kwargs.setdefault('json_encoder_cls', DjangoJSONEncoder) 13 | super().__init__(**kwargs) 14 | self.on_init() 15 | 16 | def on_init(self): 17 | """ 18 | Override at subclass. For example, initialize repositories that this 19 | app would use. 20 | """ 21 | 22 | def create_stored_event_repo(self, **kwargs): 23 | return DjangoStoredEventRepository(**kwargs) 24 | 25 | def create_transcoder(self, always_encrypt, cipher, json_decoder_cls, json_encoder_cls): 26 | return UnifiedTranscoder(json_encoder_cls=json_encoder_cls) 27 | 28 | def get_repo_for_aggregate(self, aggregate_cls): 29 | """ 30 | Returns EventSourcedRepository class for a given aggregate. 31 | """ 32 | clsname = '%sRepository' % aggregate_cls.__name__ 33 | repo_cls = type(clsname, (EventSourcedRepository,), {'domain_class': aggregate_cls}) 34 | return repo_cls(event_store=self.event_store) 35 | 36 | def get_repo_for_entity(self, entity_cls): 37 | """ 38 | Returns EventSourcedRepository class for given entity. 39 | """ 40 | msg = "`get_repo_for_entity` is depreciated. Please switch to: `get_repo_for_aggregate`" 41 | warnings.warn(msg, DeprecationWarning) 42 | return self.get_repo_for_aggregate(aggregate_cls=entity_cls) 43 | -------------------------------------------------------------------------------- /djangoevents/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig as BaseAppConfig 2 | from django.conf import settings 3 | from django.utils.module_loading import autodiscover_modules 4 | from djangoevents import DomainEvent 5 | from .exceptions import EventSchemaError 6 | from .settings import adds_schema_version_to_event_data 7 | from .schema import get_event_version 8 | from .schema import load_all_event_schemas 9 | import warnings 10 | 11 | 12 | class AppConfig(BaseAppConfig): 13 | name = 'djangoevents' 14 | 15 | def ready(self): 16 | patch_domain_event() 17 | 18 | autodiscover_modules('handlers') 19 | autodiscover_modules('aggregates') 20 | 21 | # Once all handlers & aggregates are loaded we can import aggregate schema files. 22 | # `load_scheas()` assumes that all aggregates are imported at this point. 23 | load_schemas() 24 | 25 | 26 | def get_app_module_names(): 27 | return settings.INSTALLED_APPS 28 | 29 | 30 | def load_schemas(): 31 | """ 32 | Try loading all the event schemas and complain loud if failure occurred. 33 | """ 34 | try: 35 | load_all_event_schemas() 36 | except EventSchemaError as e: 37 | warnings.warn(str(e), UserWarning) 38 | 39 | 40 | def patch_domain_event(): 41 | """ 42 | Patch `DomainEvent` to add `schema_version` to event payload. 43 | """ 44 | 45 | old_init = DomainEvent.__init__ 46 | 47 | def new_init(self, *args, **kwargs): 48 | old_init(self, *args, **kwargs) 49 | 50 | if adds_schema_version_to_event_data(): 51 | dct = self.__dict__ 52 | key = 'schema_version' 53 | if key not in dct: 54 | dct[key] = get_event_version(self.__class__) 55 | 56 | DomainEvent.__init__ = new_init 57 | -------------------------------------------------------------------------------- /djangoevents/domain.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from djangoevents.utils_abstract import abstract 4 | from djangoevents.utils_abstract import is_abstract 5 | from eventsourcing.domain.model.entity import DomainEvent 6 | from eventsourcing.domain.model.entity import EventSourcedEntity 7 | 8 | 9 | @abstract 10 | class BaseAggregate(EventSourcedEntity): 11 | """ 12 | `EventSourcedEntity` with saner mutator routing & naming: 13 | 14 | >>> class Asset(BaseAggregate): 15 | >>> class Created(BaseAggregate.Created): 16 | >>> def mutate(event, klass): 17 | >>> return klass(...) 18 | >>> 19 | >>> class Updated(DomainEvent): 20 | >>> def mutate(event, instance): 21 | >>> instance.connect = True 22 | >>> return instance 23 | """ 24 | 25 | @classmethod 26 | def is_abstract_class(cls): 27 | return is_abstract(cls) 28 | 29 | @classmethod 30 | def mutate(cls, aggregate=None, event=None): 31 | if aggregate: 32 | aggregate._validate_originator(event) 33 | 34 | if not hasattr(event, 'mutate_event'): 35 | msg = "{} does not provide a mutate_event() method.".format(event.__class__) 36 | raise NotImplementedError(msg) 37 | 38 | aggregate = event.mutate_event(event, aggregate or cls) 39 | aggregate._increment_version() 40 | return aggregate 41 | 42 | @classmethod 43 | def create_for_event(cls, event): 44 | aggregate = cls( 45 | entity_id=event.entity_id, 46 | domain_event_id=event.domain_event_id, 47 | entity_version=event.entity_version, 48 | ) 49 | return aggregate 50 | 51 | 52 | class BaseEntity(BaseAggregate): 53 | """ 54 | `EventSourcedEntity` with saner mutator routing: 55 | 56 | OBSOLETE! Interface kept for backward compatibility. 57 | """ 58 | @classmethod 59 | def is_abstract_class(cls): 60 | return super().is_abstract_class() or cls is BaseEntity 61 | 62 | @classmethod 63 | def mutate(cls, entity=None, event=None): 64 | warnings.warn("`BaseEntity` is depreciated. Please switch to: `BaseAggregate`", DeprecationWarning) 65 | return super().mutate(aggregate=entity, event=event) 66 | -------------------------------------------------------------------------------- /djangoevents/exceptions.py: -------------------------------------------------------------------------------- 1 | class DjangoeventsError(Exception): 2 | pass 3 | 4 | 5 | class AlreadyExists(DjangoeventsError): 6 | pass 7 | 8 | 9 | class EventSchemaError(DjangoeventsError): 10 | pass 11 | -------------------------------------------------------------------------------- /djangoevents/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ApplauseOSS/djangoevents/8307ac0bfb193db3a8e2199559b53caf5df3bebf/djangoevents/management/__init__.py -------------------------------------------------------------------------------- /djangoevents/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ApplauseOSS/djangoevents/8307ac0bfb193db3a8e2199559b53caf5df3bebf/djangoevents/management/commands/__init__.py -------------------------------------------------------------------------------- /djangoevents/management/commands/validate_event_schemas.py: -------------------------------------------------------------------------------- 1 | """ 2 | Parses event schemas and reports on their validity. 3 | """ 4 | from django.core.management.base import BaseCommand 5 | from djangoevents.exceptions import EventSchemaError 6 | from djangoevents.schema import load_all_event_schemas, schemas 7 | from djangoevents.settings import is_validation_enabled 8 | 9 | 10 | class Command(BaseCommand): 11 | 12 | def handle(self, *args, **options): 13 | print("=> Searching for schema files...") 14 | try: 15 | if is_validation_enabled(): 16 | print("--> Schema validation enabled. Checking state..") 17 | load_all_event_schemas() 18 | except EventSchemaError as e: 19 | print("Missing or invalid event schemas:") 20 | print(e) 21 | else: 22 | print("--> Detected events:") 23 | for item in schemas.keys(): 24 | print("--> {}".format(item)) 25 | print("--> All schemas valid!") 26 | print("=> Done.") 27 | -------------------------------------------------------------------------------- /djangoevents/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.1 on 2016-10-31 20:24 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Event', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('event_id', models.CharField(db_index=True, max_length=255)), 21 | ('event_type', models.CharField(max_length=255)), 22 | ('event_data', models.TextField()), 23 | ('aggregate_id', models.CharField(db_index=True, max_length=255)), 24 | ('aggregate_type', models.CharField(max_length=255)), 25 | ('aggregate_version', models.IntegerField()), 26 | ('create_date', models.DateTimeField()), 27 | ('metadata', models.TextField()), 28 | ('module_name', models.CharField(db_column='_module_name', max_length=255)), 29 | ('class_name', models.CharField(db_column='_class_name', max_length=255)), 30 | ('stored_entity_id', models.CharField(db_column='_stored_entity_id', max_length=255)), 31 | ], 32 | options={ 33 | 'db_table': 'event_journal', 34 | }, 35 | ), 36 | migrations.AlterUniqueTogether( 37 | name='event', 38 | unique_together=set([('aggregate_id', 'aggregate_type', 'aggregate_version')]), 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /djangoevents/migrations/0002_event_event_version.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-06 07:13 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('djangoevents', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='event', 17 | name='event_version', 18 | field=models.IntegerField(null=True), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /djangoevents/migrations/0003_auto_20171220_0404.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.8 on 2017-12-20 04:04 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('djangoevents', '0002_event_event_version'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='event', 17 | name='stored_entity_id', 18 | field=models.CharField(db_column='_stored_entity_id', db_index=True, max_length=255), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /djangoevents/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ApplauseOSS/djangoevents/8307ac0bfb193db3a8e2199559b53caf5df3bebf/djangoevents/migrations/__init__.py -------------------------------------------------------------------------------- /djangoevents/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Event(models.Model): 5 | event_id = models.CharField(max_length=255, db_index=True) 6 | event_type = models.CharField(max_length=255) 7 | event_version = models.IntegerField(null=True) 8 | event_data = models.TextField() 9 | aggregate_id = models.CharField(max_length=255, db_index=True) 10 | aggregate_type = models.CharField(max_length=255) 11 | aggregate_version = models.IntegerField() 12 | create_date = models.DateTimeField() 13 | metadata = models.TextField() 14 | module_name = models.CharField(max_length=255, db_column='_module_name') 15 | class_name = models.CharField(max_length=255, db_column='_class_name') 16 | stored_entity_id = models.CharField(max_length=255, db_column='_stored_entity_id', db_index=True) 17 | 18 | class Meta: 19 | unique_together = (("aggregate_id", "aggregate_type", "aggregate_version"),) 20 | db_table = 'event_journal' 21 | 22 | def __str__(self): 23 | return '%s.%s | %s | %s' % (self.aggregate_type, self.event_type, self.event_id, self.create_date) 24 | -------------------------------------------------------------------------------- /djangoevents/repository.py: -------------------------------------------------------------------------------- 1 | from .unifiedtranscoder import UnifiedStoredEvent 2 | from django.conf import settings 3 | from django.db.utils import IntegrityError 4 | from django.utils import timezone 5 | from djangoevents.exceptions import AlreadyExists 6 | from eventsourcing.domain.services.eventstore import AbstractStoredEventRepository 7 | from eventsourcing.domain.services.eventstore import EntityVersionDoesNotExist 8 | from eventsourcing.domain.services.transcoding import EntityVersion 9 | from eventsourcing.utils.time import timestamp_from_uuid 10 | import datetime 11 | 12 | 13 | class DjangoStoredEventRepository(AbstractStoredEventRepository): 14 | 15 | def __init__(self, *args, **kwargs): 16 | from .models import Event # import model at runtime for making top level import possible 17 | self.EventModel = Event 18 | super(DjangoStoredEventRepository, self).__init__(*args, **kwargs) 19 | 20 | def append(self, new_stored_event, new_version_number=None, max_retries=3, artificial_failure_rate=0): 21 | """ 22 | Saves given stored event in this repository. 23 | """ 24 | # Check the new event is a stored event instance. 25 | assert isinstance(new_stored_event, UnifiedStoredEvent) 26 | 27 | # Not calling validate_expected_version! 28 | 29 | # Write the event. 30 | self.write_version_and_event( 31 | new_stored_event=new_stored_event, 32 | new_version_number=new_version_number, 33 | max_retries=max_retries, 34 | artificial_failure_rate=artificial_failure_rate, 35 | ) 36 | 37 | def get_entity_version(self, stored_entity_id, version_number): 38 | events_query = self.EventModel.objects.filter(stored_entity_id=stored_entity_id)\ 39 | .filter(aggregate_version=version_number) 40 | if not events_query.exists(): 41 | raise EntityVersionDoesNotExist() 42 | 43 | return EntityVersion( 44 | entity_version_id=self.make_entity_version_id(stored_entity_id, version_number), 45 | event_id=events_query[0].event_id, 46 | ) 47 | 48 | def write_version_and_event(self, new_stored_event, new_version_number=None, max_retries=3, 49 | artificial_failure_rate=0): 50 | try: 51 | self.EventModel.objects.create( 52 | event_id=new_stored_event.event_id, 53 | event_type=new_stored_event.event_type, 54 | event_version=new_stored_event.event_version, 55 | event_data=new_stored_event.event_data, 56 | aggregate_id=new_stored_event.aggregate_id, 57 | aggregate_type=new_stored_event.aggregate_type, 58 | aggregate_version=new_stored_event.aggregate_version, 59 | create_date=make_aware_if_needed(new_stored_event.create_date), 60 | metadata=new_stored_event.metadata, 61 | module_name=new_stored_event.module_name, 62 | class_name=new_stored_event.class_name, 63 | stored_entity_id=new_stored_event.stored_entity_id, 64 | ) 65 | except IntegrityError as err: 66 | create_attempt = not new_version_number 67 | if create_attempt: 68 | msg = "Aggregate with id %r already exists" % new_stored_event.aggregate_id 69 | raise AlreadyExists(msg) 70 | else: 71 | raise err 72 | 73 | def get_entity_events(self, stored_entity_id, after=None, until=None, limit=None, query_ascending=True, 74 | results_ascending=True): 75 | events = self.EventModel.objects.filter(stored_entity_id=stored_entity_id) 76 | if query_ascending: 77 | events = events.order_by('id') 78 | else: 79 | events = events.order_by('-id') 80 | 81 | if after is not None: 82 | after_ts = datetime.datetime.fromtimestamp(timestamp_from_uuid(after)) 83 | if query_ascending: 84 | events = events.filter(create_date__gt=after_ts) 85 | else: 86 | events = events.filter(create_date__gte=after_ts) 87 | 88 | if until is not None: 89 | until_ts = datetime.datetime.fromtimestamp(timestamp_from_uuid(until)) 90 | if query_ascending: 91 | events = events.filter(create_date__lte=until_ts) 92 | else: 93 | events = events.filter(create_date__lt=until_ts) 94 | 95 | if limit is not None: 96 | events = events[:limit] 97 | 98 | events = list(events) 99 | if results_ascending != query_ascending: 100 | events.reverse() 101 | return [from_model_instance(e) for e in events] 102 | 103 | 104 | def from_model_instance(event): 105 | return UnifiedStoredEvent( 106 | event_id=event.event_id, 107 | event_type=event.event_type, 108 | event_version=event.event_version, 109 | event_data=event.event_data, 110 | aggregate_id=event.aggregate_id, 111 | aggregate_type=event.aggregate_type, 112 | aggregate_version=event.aggregate_version, 113 | create_date=event.create_date, 114 | metadata=event.metadata, 115 | module_name=event.module_name, 116 | class_name=event.class_name, 117 | stored_entity_id=event.stored_entity_id 118 | ) 119 | 120 | 121 | def make_aware_if_needed(dt): 122 | if settings.USE_TZ: 123 | return timezone.make_aware(dt) 124 | else: 125 | return dt 126 | -------------------------------------------------------------------------------- /djangoevents/schema.py: -------------------------------------------------------------------------------- 1 | """ 2 | Aggregate event avro schema validation 3 | """ 4 | 5 | import avro.schema 6 | import itertools 7 | import os 8 | import stringcase 9 | 10 | from avro.io import Validate as avro_validate 11 | from .settings import get_avro_dir 12 | from .utils import list_concrete_aggregates 13 | from .utils import list_aggregate_events 14 | from .utils import event_to_json 15 | from .exceptions import EventSchemaError 16 | 17 | 18 | schemas = {} 19 | 20 | 21 | def load_all_event_schemas(): 22 | """ 23 | Initializes aggregate event schemas lookup cache. 24 | """ 25 | errors = [] 26 | for aggregate in list_concrete_aggregates(): 27 | for event in list_aggregate_events(aggregate_cls=aggregate): 28 | try: 29 | schemas[event] = load_event_schema(aggregate, event) 30 | except EventSchemaError as e: 31 | errors.append(str(e)) 32 | 33 | # Serve all schema errors at once not iteratively. 34 | if errors: 35 | raise EventSchemaError("\n".join(errors)) 36 | 37 | return schemas 38 | 39 | 40 | def set_event_version(aggregate_cls, event_cls, avro_dir=None): 41 | avro_dir = avro_dir or get_avro_dir() 42 | event_cls.version = _find_highest_event_version_based_on_schemas(aggregate_cls, event_cls, avro_dir) 43 | 44 | 45 | def get_event_version(event_cls): 46 | return getattr(event_cls, 'version', None) or 1 47 | 48 | 49 | def load_event_schema(aggregate, event): 50 | set_event_version(aggregate, event) 51 | spec_path = event_to_schema_path(aggregate, event) 52 | 53 | try: 54 | with open(spec_path) as fp: 55 | return parse_event_schema(fp.read()) 56 | except FileNotFoundError: 57 | msg = "No event schema found for: {event} (expecting file at:{path})." 58 | raise EventSchemaError(msg.format(event=event, path=spec_path)) 59 | except avro.schema.SchemaParseException as e: 60 | msg = "Can't parse schema for event: {event} from {path}." 61 | raise EventSchemaError(msg.format(event=event, path=spec_path)) from e 62 | 63 | 64 | def event_to_schema_path(aggregate_cls, event_cls, avro_dir=None): 65 | avro_dir = avro_dir or get_avro_dir() 66 | version = get_event_version(event_cls) 67 | return _event_to_schema_path(aggregate_cls, event_cls, avro_dir, version) 68 | 69 | 70 | def _find_highest_event_version_based_on_schemas(aggregate_cls, event_cls, avro_dir): 71 | version = None 72 | for version_candidate in itertools.count(1): 73 | schema_path = _event_to_schema_path(aggregate_cls, event_cls, avro_dir, version_candidate) 74 | if os.path.exists(schema_path): 75 | version = version_candidate 76 | else: 77 | break 78 | 79 | return version 80 | 81 | 82 | def _event_to_schema_path(aggregate_cls, event_cls, avro_dir, version): 83 | aggregate_name = decode_cls_name(aggregate_cls) 84 | event_name = decode_cls_name(event_cls) 85 | 86 | filename = "v{version}_{aggregate_name}_{event_name}.json".format( 87 | aggregate_name=aggregate_name, event_name=event_name, version=version) 88 | 89 | return os.path.join(avro_dir, aggregate_name, filename) 90 | 91 | 92 | def decode_cls_name(cls): 93 | """ 94 | Convert camel case class name to snake case names used in event documentation. 95 | """ 96 | return stringcase.snakecase(cls.__name__) 97 | 98 | 99 | def parse_event_schema(spec_body): 100 | schema = avro.schema.Parse(spec_body) 101 | return schema 102 | 103 | 104 | def get_schema_for_event(event_cls): 105 | if event_cls not in schemas: 106 | raise EventSchemaError("Cached Schema not found for: {}".format(event_cls)) 107 | return schemas[event_cls] 108 | 109 | 110 | def validate_event(event, schema=None): 111 | schema = schema or get_schema_for_event(event.__class__) 112 | return avro_validate(schema, event_to_json(event)) 113 | -------------------------------------------------------------------------------- /djangoevents/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from django.conf import settings 3 | from django.core.exceptions import ImproperlyConfigured 4 | 5 | 6 | _DEFAULTS = { 7 | 'EVENT_SCHEMA_VALIDATION': { 8 | 'ENABLED': False, 9 | 'SCHEMA_DIR': 'avro', 10 | }, 11 | 'ADDS_SCHEMA_VERSION_TO_EVENT_DATA': False, 12 | } 13 | 14 | 15 | def get_config(): 16 | """ 17 | Function getting djangoevents configuration defined in Django settings. 18 | We use a function because Django settings can be mutable (useful for example in tests). 19 | """ 20 | return getattr(settings, 'DJANGOEVENTS_CONFIG', _DEFAULTS) 21 | 22 | 23 | def is_validation_enabled(): 24 | config = get_config() 25 | return config.get('EVENT_SCHEMA_VALIDATION', {}).get('ENABLED', False) 26 | 27 | 28 | def get_avro_dir(): 29 | config = get_config() 30 | 31 | try: 32 | avro_folder = config.get('EVENT_SCHEMA_VALIDATION', {})['SCHEMA_DIR'] 33 | except KeyError: 34 | raise ImproperlyConfigured("Please define `SCHEMA_DIR`") 35 | 36 | avro_dir = os.path.abspath(os.path.join(settings.BASE_DIR, '..', avro_folder)) 37 | return avro_dir 38 | 39 | 40 | def adds_schema_version_to_event_data(): 41 | config = get_config() 42 | return config.get('ADDS_SCHEMA_VERSION_TO_EVENT_DATA', False) 43 | -------------------------------------------------------------------------------- /djangoevents/shortcuts.py: -------------------------------------------------------------------------------- 1 | from django.http import Http404 2 | import warnings 3 | 4 | 5 | def get_aggregate_or_404(repo, aggregate_id): 6 | """ 7 | Uses `repo` to return most recent state of the aggregate. Raises a Http404 exception if the 8 | `aggregate_id` does not exist. 9 | 10 | `repo` has to be a EventSourcedRepository object. 11 | """ 12 | if aggregate_id not in repo: 13 | raise Http404 14 | return repo[aggregate_id] 15 | 16 | 17 | def get_entity_or_404(repo, entity_id): 18 | warnings.warn("`get_entity_or_404` is depreciated. Please switch to: `get_aggregate_or_404`", DeprecationWarning) 19 | return get_aggregate_or_404(repo, entity_id) 20 | -------------------------------------------------------------------------------- /djangoevents/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ApplauseOSS/djangoevents/8307ac0bfb193db3a8e2199559b53caf5df3bebf/djangoevents/tests/__init__.py -------------------------------------------------------------------------------- /djangoevents/tests/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ApplauseOSS/djangoevents/8307ac0bfb193db3a8e2199559b53caf5df3bebf/djangoevents/tests/settings/__init__.py -------------------------------------------------------------------------------- /djangoevents/tests/settings/settings_test.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | import django 4 | 5 | DEBUG = False 6 | 7 | SECRET_KEY = ''.join([random.choice(string.ascii_letters) for x in range(40)]) 8 | 9 | 10 | INSTALLED_APPS = ( 11 | 'django.contrib.auth', 12 | 'django.contrib.contenttypes', 13 | 'django.contrib.sessions', 14 | 'django.contrib.sites', 15 | 'django.contrib.admin', 16 | 'django.contrib.messages', 17 | 'djangoevents', 18 | ) 19 | 20 | MIDDLEWARE = ( 21 | 'django.middleware.common.CommonMiddleware', 22 | 'django.contrib.sessions.middleware.SessionMiddleware', 23 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 24 | 'django.contrib.messages.middleware.MessageMiddleware', 25 | ) 26 | 27 | if django.VERSION < (1, 10): 28 | MIDDLEWARE_CLASSES = MIDDLEWARE 29 | 30 | TEST_RUNNER = 'django.test.runner.DiscoverRunner' 31 | 32 | SITE_ID = 1 33 | 34 | 35 | # Database specific 36 | 37 | DATABASES = { 38 | 'default': { 39 | 'ENGINE': 'django.db.backends.sqlite3', 40 | 'NAME': ':memory:', 41 | } 42 | } 43 | 44 | TEMPLATES = [ 45 | { 46 | }, 47 | ] 48 | -------------------------------------------------------------------------------- /djangoevents/tests/settings/settings_test_local.py: -------------------------------------------------------------------------------- 1 | """ 2 | Same as settings_test but instead of migration 3 | uses provided sql. This is for test to make sure 4 | that Event model works fine with db scheme 5 | all teams agreed on. 6 | """ 7 | from .settings_test import * 8 | import os 9 | 10 | 11 | DATABASES = { 12 | 'default': { 13 | 'ENGINE': 'django.db.backends.mysql', 14 | 'HOST': os.environ.get("MYSQL_HOSTNAME", '127.0.0.1'), 15 | 'PORT': os.environ.get("MYSQL_PORT", 3306), 16 | 'NAME': os.environ.get("MYSQL_DATABASE", 'djangoevents'), 17 | 'USER': os.environ.get("MYSQL_USER", 'root'), 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /djangoevents/tests/test_domain.py: -------------------------------------------------------------------------------- 1 | from ..domain import BaseEntity, DomainEvent 2 | from ..schema import get_event_version 3 | from ..schema import set_event_version 4 | from ..unifiedtranscoder import UnifiedTranscoder 5 | from django.core.serializers.json import DjangoJSONEncoder 6 | from django.test import override_settings 7 | from unittest import mock 8 | 9 | import os 10 | import pytest 11 | 12 | 13 | class SampleEntity(BaseEntity): 14 | class Created(BaseEntity.Created): 15 | def mutate_event(self, event, klass): 16 | return klass(entity_id=event.entity_id, 17 | entity_version=event.entity_version, 18 | domain_event_id=event.domain_event_id) 19 | 20 | class Updated(DomainEvent): 21 | def mutate_event(self, event, entity): 22 | entity.is_dirty = True 23 | return entity 24 | 25 | class Closed(DomainEvent): 26 | # no mutate_event 27 | pass 28 | 29 | def __init__(self, is_dirty=False, **kwargs): 30 | super().__init__(**kwargs) 31 | self.is_dirty = is_dirty 32 | 33 | 34 | def test_base_entity_calls_mutator(): 35 | create_event = SampleEntity.Created(entity_id=1) 36 | entity = SampleEntity.mutate(event=create_event) 37 | 38 | close_event = SampleEntity.Updated(entity_id=entity.id, entity_version=entity.version) 39 | entity = SampleEntity.mutate(event=close_event, entity=entity) 40 | assert entity.is_dirty is True 41 | 42 | 43 | def test_base_entity_requires_mutator(): 44 | create_event = SampleEntity.Created(entity_id=1) 45 | entity = SampleEntity.mutate(event=create_event) 46 | 47 | close_event = SampleEntity.Closed(entity_id=entity.id, entity_version=entity.version) 48 | 49 | with pytest.raises(NotImplementedError): 50 | SampleEntity.mutate(event=close_event, entity=entity) 51 | 52 | 53 | def test_create_for_event(): 54 | event = SampleEntity.Created( 55 | entity_id='ENTITY_ID', 56 | domain_event_id='DOMAIN_EVENT_ID', 57 | entity_version=0, 58 | ) 59 | obj = SampleEntity.create_for_event(event) 60 | 61 | assert obj.id == 'ENTITY_ID' 62 | assert obj.version == 0 63 | 64 | 65 | @override_settings(BASE_DIR='/path/to/proj/src/') 66 | def test_version_1(): 67 | assert get_event_version(SampleEntity.Created) == 1 68 | 69 | 70 | def test_version_4(tmpdir): 71 | # make temporary directory structure 72 | avro_dir = tmpdir.mkdir('avro_dir') 73 | entity_dir = avro_dir.mkdir('sample_entity') 74 | 75 | original_version = getattr(SampleEntity.Created, 'version', None) 76 | try: 77 | for version in range(1, 4): 78 | # make empty schema file 79 | expected_schema_path = os.path.join(entity_dir.strpath, 'v{}_sample_entity_created.json'.format(version)) 80 | with open(expected_schema_path, 'w'): 81 | pass 82 | 83 | # refresh version 84 | set_event_version(SampleEntity, SampleEntity.Created, avro_dir=avro_dir.strpath) 85 | 86 | assert get_event_version(SampleEntity.Created) == version 87 | finally: 88 | SampleEntity.Created.version = original_version 89 | 90 | 91 | @override_settings(DJANGOEVENTS_CONFIG={ 92 | 'ADDS_SCHEMA_VERSION_TO_EVENT_DATA': False, 93 | }) 94 | def test_events_dont_have_schema_version_when_disabled(): 95 | event = SampleEntity.Created(entity_id=1) 96 | assert not hasattr(event, 'schema_version') 97 | 98 | 99 | @override_settings(DJANGOEVENTS_CONFIG={ 100 | 'ADDS_SCHEMA_VERSION_TO_EVENT_DATA': True, 101 | }) 102 | def test_events_have_schema_version_when_enabled(): 103 | event = SampleEntity.Created(entity_id=1) 104 | assert event.schema_version == 1 105 | 106 | 107 | @override_settings(DJANGOEVENTS_CONFIG={ 108 | 'ADDS_SCHEMA_VERSION_TO_EVENT_DATA': True, 109 | }) 110 | def test_events_have_correct_schema_version(): 111 | with mock.patch.object(SampleEntity.Created, 'version', 666): 112 | event = SampleEntity.Created(entity_id=1) 113 | assert event.schema_version == 666 114 | 115 | 116 | @override_settings(DJANGOEVENTS_CONFIG={ 117 | 'ADDS_SCHEMA_VERSION_TO_EVENT_DATA': True, 118 | }) 119 | def test_explicitly_provided_schema_version_is_not_overridden(): 120 | with mock.patch.object(SampleEntity.Created, 'version', 2): 121 | event = SampleEntity.Created(entity_id=1, schema_version=3) 122 | assert event.schema_version == 3 123 | 124 | 125 | @override_settings(DJANGOEVENTS_CONFIG={ 126 | 'ADDS_SCHEMA_VERSION_TO_EVENT_DATA': True, 127 | }) 128 | def test_schema_version_is_not_overridden_when_serialized_and_deserialized(): 129 | transcoder = UnifiedTranscoder(json_encoder_cls=DjangoJSONEncoder) 130 | 131 | with mock.patch.object(SampleEntity.Created, 'version', 2): 132 | event = SampleEntity.Created(entity_id=1) 133 | 134 | stored_event = transcoder.serialize(event) 135 | 136 | with mock.patch.object(SampleEntity.Created, 'version', 3): 137 | entity = transcoder.deserialize(stored_event) 138 | 139 | assert entity.schema_version == 2 140 | -------------------------------------------------------------------------------- /djangoevents/tests/test_management.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ApplauseOSS/djangoevents/8307ac0bfb193db3a8e2199559b53caf5df3bebf/djangoevents/tests/test_management.py -------------------------------------------------------------------------------- /djangoevents/tests/test_repo.py: -------------------------------------------------------------------------------- 1 | from ..app import EventSourcingWithDjango 2 | from ..models import Event 3 | from ..repository import from_model_instance 4 | from ..unifiedtranscoder import UnifiedStoredEvent 5 | from eventsourcing.domain.model.entity import EventSourcedEntity 6 | from eventsourcing.domain.services.transcoding import EntityVersion 7 | from eventsourcing.utils.time import timestamp_from_uuid 8 | from datetime import datetime 9 | from django.db import transaction 10 | from djangoevents.exceptions import AlreadyExists 11 | import json 12 | import pytest 13 | 14 | 15 | class SampleAggregate(EventSourcedEntity): 16 | class Created(EventSourcedEntity.Created): 17 | pass 18 | 19 | 20 | class SampleApp(EventSourcingWithDjango): 21 | def on_init(self): 22 | self.repo = self.get_repo_for_entity(SampleAggregate) 23 | 24 | 25 | # uuid1 contains time 26 | uuids = ['7ab23d4c-a520-11e6-80f5-76304dec7eb7', 27 | '846713a8-a520-11e6-80f5-76304dec7eb7', 28 | '917e42a0-a520-11e6-80f5-76304dec7eb7', 29 | '98ef1faa-a520-11e6-80f5-76304dec7eb7', 30 | '9e89b9de-a520-11e6-80f5-76304dec7eb7', 31 | 'a678b7a8-a520-11e6-80f5-76304dec7eb7'] 32 | 33 | 34 | def new_stored_event(event_id, event_type, event_data, aggregate_id, aggregate_version): 35 | return UnifiedStoredEvent( 36 | event_id=event_id, 37 | event_type=event_type, 38 | event_version=1, 39 | event_data=json.dumps(event_data), 40 | aggregate_id=aggregate_id, 41 | aggregate_type='SampleAggregate', 42 | aggregate_version=aggregate_version, 43 | create_date=datetime.fromtimestamp(timestamp_from_uuid(event_id)), 44 | stored_entity_id='SampleAggregate::' + aggregate_id, 45 | metadata='', 46 | module_name='', 47 | class_name='', 48 | ) 49 | 50 | 51 | def save_events(repo, stored_events): 52 | for e in stored_events: 53 | repo.append(e) 54 | 55 | 56 | @pytest.fixture 57 | def repo(): 58 | app = SampleApp() 59 | return app.stored_event_repo 60 | 61 | 62 | @pytest.fixture 63 | def stored_events(): 64 | return [new_stored_event(uuids[i], 'Created', {'title': 'test'}, uuids[0], i) for i, uuid in enumerate(uuids)] 65 | 66 | 67 | @pytest.mark.django_db 68 | def test_repo_append(repo): 69 | test_stored_event = new_stored_event(uuids[0], 'Created', {'title': 'test'}, uuids[1], 1) 70 | 71 | # test basic save and get 72 | repo.append(test_stored_event) 73 | events = Event.objects.filter(stored_entity_id=test_stored_event.stored_entity_id) 74 | assert len(events) == 1 75 | assert test_stored_event == from_model_instance(events[0]) 76 | 77 | # test unique constraint 78 | with transaction.atomic(): 79 | test_stored_event2 = new_stored_event(uuids[2], 'Updated', {'title': 'updated test'}, uuids[1], 1) 80 | with pytest.raises(AlreadyExists): 81 | repo.append(test_stored_event2) 82 | 83 | # multiple saves 84 | test_stored_event3 = new_stored_event(uuids[2], 'Updated', {'title': 'updated test'}, uuids[1], 2) 85 | repo.append(test_stored_event3) 86 | events = Event.objects.filter(stored_entity_id=test_stored_event.stored_entity_id) 87 | assert len(events) == 2 88 | assert test_stored_event == from_model_instance(events[0]) 89 | assert test_stored_event3 == from_model_instance(events[1]) 90 | 91 | 92 | @pytest.mark.django_db 93 | def test_repo_get_entity_version(repo): 94 | test_stored_event = new_stored_event(uuids[0], 'Created', {'title': 'test'}, uuids[1], 1) 95 | repo.append(test_stored_event) 96 | entity_version = repo.get_entity_version(test_stored_event.stored_entity_id, 1) 97 | assert isinstance(entity_version, EntityVersion) 98 | assert entity_version.entity_version_id == 'SampleAggregate::' + uuids[1] + '::version::1' 99 | assert entity_version.event_id == uuids[0] 100 | 101 | 102 | @pytest.mark.django_db 103 | def test_repo_get_entity_events(repo, stored_events): 104 | save_events(repo, stored_events) 105 | 106 | # test get all 107 | retrieved_events = repo.get_entity_events(stored_events[0].stored_entity_id) 108 | assert retrieved_events == stored_events 109 | 110 | 111 | @pytest.mark.django_db 112 | def test_repo_get_entity_events_slicing(repo, stored_events): 113 | save_events(repo, stored_events) 114 | 115 | # test after 116 | retrieved_events = repo.get_entity_events(stored_events[0].stored_entity_id, after=stored_events[2].event_id) 117 | assert retrieved_events == stored_events[3:] 118 | 119 | # test until 120 | retrieved_events = repo.get_entity_events(stored_events[0].stored_entity_id, until=stored_events[2].event_id) 121 | assert retrieved_events == stored_events[:3] 122 | 123 | # test after + until 124 | retrieved_events = repo.get_entity_events(stored_events[0].stored_entity_id, after=stored_events[1].event_id, 125 | until=stored_events[4].event_id) 126 | assert retrieved_events == stored_events[2:5] 127 | 128 | # test limit 129 | retrieved_events = repo.get_entity_events(stored_events[0].stored_entity_id, limit=3) 130 | assert retrieved_events == stored_events[:3] 131 | 132 | 133 | @pytest.mark.django_db 134 | def test_repo_get_entity_events_ordering(repo, stored_events): 135 | save_events(repo, stored_events) 136 | 137 | # test query_ascending 138 | retrieved_events = repo.get_entity_events(stored_events[0].stored_entity_id, limit=3, query_ascending=False) 139 | assert retrieved_events == stored_events[-3:] 140 | 141 | # test results_ascending 142 | retrieved_events = repo.get_entity_events(stored_events[0].stored_entity_id, results_ascending=False) 143 | assert retrieved_events == stored_events[::-1] 144 | 145 | # test limit + query_ascending + results_ascending 146 | retrieved_events = repo.get_entity_events(stored_events[0].stored_entity_id, limit=3, query_ascending=False, 147 | results_ascending=False) 148 | assert retrieved_events == stored_events[-3:][::-1] 149 | 150 | # test after + until + query_ascending 151 | retrieved_events = repo.get_entity_events(stored_events[0].stored_entity_id, after=stored_events[1].event_id, 152 | until=stored_events[4].event_id, query_ascending=False) 153 | assert retrieved_events == stored_events[1:4] 154 | 155 | 156 | @pytest.mark.django_db 157 | def test_append_wrong_type(repo): 158 | # only allow UnifiedStoredEvent to be saved 159 | with transaction.atomic(): 160 | with pytest.raises(AssertionError): 161 | repo.append('wrong object type') 162 | -------------------------------------------------------------------------------- /djangoevents/tests/test_schema.py: -------------------------------------------------------------------------------- 1 | import avro.schema 2 | from djangoevents.exceptions import EventSchemaError 3 | 4 | import djangoevents.schema as schema 5 | import json 6 | import os 7 | import pytest 8 | import shutil 9 | import tempfile 10 | 11 | from djangoevents.domain import BaseAggregate 12 | from djangoevents.domain import DomainEvent 13 | from io import StringIO 14 | from django.test import override_settings 15 | from unittest import mock 16 | from .test_domain import SampleEntity 17 | from ..schema import set_event_version 18 | 19 | 20 | class Project(BaseAggregate): 21 | class Created(BaseAggregate.Created): 22 | def mutate_event(self, event, klass): 23 | return klass( 24 | entity_id=event.entity_id, 25 | entity_version=event.entity_version, 26 | domain_event_id=event.domain_event_id, 27 | name=event.name, 28 | ) 29 | 30 | class Closed(DomainEvent): 31 | # no mutate_event 32 | pass 33 | 34 | def __init__(self, project_id, name, **kwargs): 35 | super().__init__(**kwargs) 36 | self.project_id = project_id 37 | self.name = name 38 | 39 | 40 | PROJECT_CREATED_SCHEMA = json.dumps({ 41 | "name": "project_created", 42 | "type": "record", 43 | "doc": "Event posted when a project has been created", 44 | "namespace": "services.project_svc", 45 | "fields": [ 46 | { 47 | "name": "entity_id", 48 | "type": "string", 49 | "doc": "ID of a the asset." 50 | }, 51 | { 52 | "name": "entity_version", 53 | "type": "string", 54 | "doc": "Aggregate revision" 55 | }, 56 | { 57 | "name": "domain_event_id", 58 | "type": "string", 59 | "doc": "ID of the last modifying event" 60 | }, 61 | { 62 | "name": "name", 63 | "type": "string", 64 | "doc": "name of the project" 65 | } 66 | ] 67 | }) 68 | 69 | 70 | @override_settings(BASE_DIR='/path/to/proj/src/') 71 | @mock.patch.object(schema, 'list_concrete_aggregates', return_value=[Project]) 72 | @mock.patch.object(schema, 'load_event_schema') 73 | def test_load_all_event_schemas(load_schema, load_aggs): 74 | schema.load_all_event_schemas() 75 | load_schema.assert_called_once_with(Project, Project.Created) 76 | 77 | 78 | @override_settings(BASE_DIR='/path/to/proj/src/') 79 | @mock.patch.object(schema, 'list_concrete_aggregates', return_value=[Project]) 80 | def test_load_all_event_schemas_missing_specs(list_aggs): 81 | with pytest.raises(EventSchemaError) as e: 82 | schema.load_all_event_schemas() 83 | 84 | path = "/path/to/proj/avro/project/v1_project_created.json" 85 | msg = "No event schema found for: {cls} (expecting file at:{path}).".format(cls=Project.Created, path=path) 86 | assert msg in str(e.value) 87 | 88 | 89 | @override_settings(BASE_DIR='/path/to/proj/src/') 90 | def test_valid_event_to_schema_path(): 91 | SampleEntity.Created.version = None 92 | avro_path = schema.event_to_schema_path(aggregate_cls=SampleEntity, event_cls=SampleEntity.Created) 93 | assert avro_path == "/path/to/proj/avro/sample_entity/v1_sample_entity_created.json" 94 | 95 | 96 | @override_settings(BASE_DIR='/path/to/proj/src/') 97 | def test_event_to_schema_path_chooses_file_with_the_highest_version(): 98 | try: 99 | # make temporary directory structure 100 | temp_dir = tempfile.mkdtemp() 101 | entity_dir = os.path.join(temp_dir, 'sample_entity') 102 | os.mkdir(entity_dir) 103 | 104 | for version in range(1, 4): 105 | # make empty schema file 106 | expected_schema_path = os.path.join(entity_dir, 'v{}_sample_entity_created.json'.format(version)) 107 | with open(expected_schema_path, 'w'): 108 | pass 109 | 110 | # refresh version 111 | set_event_version(SampleEntity, SampleEntity.Created, avro_dir=temp_dir) 112 | 113 | # check path 114 | schema_path = schema.event_to_schema_path( 115 | aggregate_cls=SampleEntity, 116 | event_cls=SampleEntity.Created, 117 | avro_dir=temp_dir, 118 | ) 119 | assert schema_path == expected_schema_path 120 | finally: 121 | # remove temporary directory 122 | shutil.rmtree(temp_dir) 123 | 124 | 125 | def test_parse_invalid_event_schema(): 126 | fp = StringIO("Invalid schema") 127 | 128 | with pytest.raises(avro.schema.SchemaParseException): 129 | schema.parse_event_schema(fp) 130 | 131 | 132 | def test_parse_valid_event_schema(): 133 | evt_schema = schema.parse_event_schema(PROJECT_CREATED_SCHEMA) 134 | assert evt_schema is not None 135 | 136 | 137 | def test_validate_valid_event(): 138 | class TestEvent: 139 | def __init__(self): 140 | self.entity_id = "1" 141 | self.entity_version = "XYZ" 142 | self.domain_event_id = "2" 143 | self.name = "Awesome Project" 144 | 145 | event = TestEvent() 146 | evt_schema = avro.schema.Parse(PROJECT_CREATED_SCHEMA) 147 | ret = schema.validate_event(event, evt_schema) 148 | assert ret is True 149 | 150 | 151 | def test_validate_invalid_event(): 152 | class TestEvent: 153 | def __init__(self): 154 | self.test = 'test' 155 | 156 | event = TestEvent() 157 | evt_schema = avro.schema.Parse(PROJECT_CREATED_SCHEMA) 158 | ret = schema.validate_event(event, evt_schema) 159 | assert ret is False 160 | 161 | 162 | def test_decode_cls_name(): 163 | class LongName: 164 | pass 165 | 166 | class Shortname: 167 | pass 168 | 169 | assert schema.decode_cls_name(LongName) == 'long_name' 170 | assert schema.decode_cls_name(Shortname) == 'shortname' 171 | -------------------------------------------------------------------------------- /djangoevents/tests/test_shortcuts.py: -------------------------------------------------------------------------------- 1 | from ..shortcuts import get_entity_or_404, get_aggregate_or_404 2 | from django.http import Http404 3 | 4 | import pytest 5 | 6 | 7 | def test_get_entity_or_404_item_exists(): 8 | """ 9 | ES store provides a dictionary style item verification. 10 | """ 11 | repo = {'id': 'entity'} 12 | assert get_entity_or_404(repo, 'id') == 'entity' 13 | 14 | 15 | def test_get_entity_or_404_item_does_not_exists(): 16 | repo = {} 17 | with pytest.raises(Http404) as exc_info: 18 | get_entity_or_404(repo, 'id') 19 | 20 | 21 | def test_get_aggregate_or_404_item_exists(): 22 | repo = {'id': 'aggregate'} 23 | assert get_aggregate_or_404(repo, 'id') == 'aggregate' 24 | 25 | 26 | def test_get_aggregate_or_404_item_does_not_exists(): 27 | repo = {} 28 | with pytest.raises(Http404) as exc_info: 29 | get_aggregate_or_404(repo, 'id') 30 | -------------------------------------------------------------------------------- /djangoevents/tests/test_store.py: -------------------------------------------------------------------------------- 1 | import djangoevents 2 | from unittest import mock 3 | 4 | 5 | @mock.patch.object(djangoevents, 'is_validation_enabled', return_value=True) 6 | @mock.patch.object(djangoevents, 'validate_event', return_value=True) 7 | @mock.patch.object(djangoevents, 'es_publish', return_value=True) 8 | def test_store_event_validation_enabled(publish, validate_event, validation_enabled): 9 | evt = {} 10 | djangoevents.store_event(evt) 11 | 12 | assert validation_enabled.call_count == 1 13 | assert validate_event.call_args_list == [mock.call(evt)] 14 | assert publish.call_args_list == [mock.call(evt)] 15 | 16 | 17 | @mock.patch.object(djangoevents, 'is_validation_enabled', return_value=False) 18 | @mock.patch.object(djangoevents, 'validate_event', return_value=True) 19 | @mock.patch.object(djangoevents, 'es_publish', return_value=True) 20 | def test_store_event_validation_disabled(publish, validate_event, validation_enabled): 21 | evt = {} 22 | djangoevents.store_event(evt) 23 | 24 | assert validation_enabled.call_count == 1 25 | assert validate_event.call_count == 0 26 | assert publish.call_args_list == [mock.call(evt)] 27 | 28 | 29 | @mock.patch.object(djangoevents, 'is_validation_enabled', return_value=False) 30 | @mock.patch.object(djangoevents, 'validate_event', return_value=True) 31 | @mock.patch.object(djangoevents, 'es_publish', return_value=True) 32 | def test_store_event_validation_disabled_force(publish, validate_event, validation_enabled): 33 | evt = {} 34 | djangoevents.store_event(evt, force_validate=True) 35 | 36 | assert validation_enabled.call_count == 1 37 | assert validate_event.call_args_list == [mock.call(evt)] 38 | assert publish.call_args_list == [mock.call(evt)] 39 | -------------------------------------------------------------------------------- /djangoevents/tests/test_unifiedtranscoder.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from ..unifiedtranscoder import UnifiedTranscoder 3 | from eventsourcing.domain.model.entity import EventSourcedEntity 4 | from django.core.serializers.json import DjangoJSONEncoder 5 | from django.test.utils import override_settings 6 | from unittest import mock 7 | 8 | 9 | class SampleAggregate(EventSourcedEntity): 10 | class Created(EventSourcedEntity.Created): 11 | pass 12 | 13 | class Updated(EventSourcedEntity.AttributeChanged): 14 | event_type = 'overridden_event_type' 15 | 16 | 17 | def override_schema_version_setting(adds): 18 | return override_settings(DJANGOEVENTS_CONFIG={ 19 | 'ADDS_SCHEMA_VERSION_TO_EVENT_DATA': adds, 20 | }) 21 | 22 | 23 | def test_serialize_and_deserialize_1(): 24 | transcoder = UnifiedTranscoder(json_encoder_cls=DjangoJSONEncoder) 25 | # test serialize 26 | created = SampleAggregate.Created(entity_id='b089a0a6-e0b3-480d-9382-c47f99103b3d', attr1='val1', attr2='val2', 27 | metadata={'command_id': 123}) 28 | created_stored_event = transcoder.serialize(created) 29 | assert created_stored_event.event_type == 'sample_aggregate_created' 30 | assert created_stored_event.event_version == 1 31 | assert created_stored_event.event_data == '{"attr1":"val1","attr2":"val2"}' 32 | assert created_stored_event.aggregate_id == 'b089a0a6-e0b3-480d-9382-c47f99103b3d' 33 | assert created_stored_event.aggregate_version == 0 34 | assert created_stored_event.aggregate_type == 'SampleAggregate' 35 | assert created_stored_event.module_name == 'djangoevents.tests.test_unifiedtranscoder' 36 | assert created_stored_event.class_name == 'SampleAggregate.Created' 37 | assert created_stored_event.metadata == '{"command_id":123}' 38 | # test deserialize 39 | created_copy = transcoder.deserialize(created_stored_event) 40 | assert 'metadata' not in created_copy.__dict__ 41 | created.__dict__.pop('metadata') # metadata is not included in deserialization 42 | assert created.__dict__ == created_copy.__dict__ 43 | 44 | 45 | def test_serialize_and_deserialize_2(): 46 | transcoder = UnifiedTranscoder(json_encoder_cls=DjangoJSONEncoder) 47 | # test serialize 48 | updated = SampleAggregate.Updated(entity_id='b089a0a6-e0b3-480d-9382-c47f99103b3d', entity_version=10, attr1='val1', 49 | attr2='val2', metadata={'command_id': 123}) 50 | updated_stored_event = transcoder.serialize(updated) 51 | assert updated_stored_event.event_type == 'overridden_event_type' 52 | assert updated_stored_event.event_version == 1 53 | assert updated_stored_event.event_data == '{"attr1":"val1","attr2":"val2"}' 54 | assert updated_stored_event.aggregate_id == 'b089a0a6-e0b3-480d-9382-c47f99103b3d' 55 | assert updated_stored_event.aggregate_version == 10 56 | assert updated_stored_event.aggregate_type == 'SampleAggregate' 57 | assert updated_stored_event.module_name == 'djangoevents.tests.test_unifiedtranscoder' 58 | assert updated_stored_event.class_name == 'SampleAggregate.Updated' 59 | assert updated_stored_event.metadata == '{"command_id":123}' 60 | # test deserialize 61 | updated_copy = transcoder.deserialize(updated_stored_event) 62 | assert 'metadata' not in updated_copy.__dict__ 63 | updated.__dict__.pop('metadata') # metadata is not included in deserialization 64 | assert updated.__dict__ == updated_copy.__dict__ 65 | 66 | 67 | @override_schema_version_setting(adds=False) 68 | def test_serializer_doesnt_include_schema_version_when_its_disabled(): 69 | transcoder = UnifiedTranscoder(json_encoder_cls=DjangoJSONEncoder) 70 | event = SampleAggregate.Created(entity_id='b089a0a6-e0b3-480d-9382-c47f99103b3d', attr1='val1', attr2='val2') 71 | serialized_event = transcoder.serialize(event) 72 | assert serialized_event.event_data == '{"attr1":"val1","attr2":"val2"}' 73 | 74 | 75 | @override_schema_version_setting(adds=True) 76 | def test_serializer_includes_schema_version_when_its_enabled(): 77 | transcoder = UnifiedTranscoder(json_encoder_cls=DjangoJSONEncoder) 78 | event = SampleAggregate.Created(entity_id='b089a0a6-e0b3-480d-9382-c47f99103b3d', attr1='val1', attr2='val2') 79 | serialized_event = transcoder.serialize(event) 80 | assert serialized_event.event_data == '{"attr1":"val1","attr2":"val2","schema_version":1}' 81 | 82 | 83 | def test_deserializer_uses_none_as_schema_version_default(): 84 | transcoder = UnifiedTranscoder(json_encoder_cls=DjangoJSONEncoder) 85 | 86 | with override_schema_version_setting(adds=False): 87 | event = SampleAggregate.Created(entity_id='123') 88 | 89 | serialized_event = transcoder.serialize(event) 90 | assert serialized_event.event_data == '{}' 91 | 92 | with override_schema_version_setting(adds=True): 93 | event = transcoder.deserialize(serialized_event) 94 | 95 | assert event.schema_version is None 96 | 97 | 98 | def test_metadata_is_optional(): 99 | transcoder = UnifiedTranscoder(json_encoder_cls=DjangoJSONEncoder) 100 | created = SampleAggregate.Created(entity_id='b089a0a6-e0b3-480d-9382-c47f99103b3d') 101 | 102 | try: 103 | transcoder.serialize(created) 104 | except AttributeError: 105 | pytest.fail('Serialization of an event without metadata should not raise an exception.') 106 | 107 | 108 | def test_transcoder_passes_all_attributes_to_event_constructor(): 109 | attributes = { 110 | 'entity_id': 'b089a0a6-e0b3-480d-9382-c47f99103b3d', 111 | 'foo': 0, 112 | 'bar': 1, 113 | } 114 | 115 | event = SampleAggregate.Created(**attributes) 116 | transcoder = UnifiedTranscoder(json_encoder_cls=DjangoJSONEncoder) 117 | serialized_event = transcoder.serialize(event) 118 | 119 | init_call_args = None 120 | def init(self, *args, **kwargs): 121 | nonlocal init_call_args 122 | init_call_args = (args, kwargs) 123 | 124 | with mock.patch.object(SampleAggregate.Created, '__init__', init): 125 | transcoder.deserialize(serialized_event) 126 | 127 | args, kwargs = init_call_args 128 | assert args == tuple() 129 | 130 | for key, value in attributes.items(): 131 | assert key in kwargs 132 | assert kwargs[key] == value 133 | -------------------------------------------------------------------------------- /djangoevents/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from djangoevents.domain import BaseAggregate 2 | from djangoevents.domain import DomainEvent 3 | from djangoevents.utils_abstract import abstract 4 | from ..utils import camel_case_to_snake_case 5 | from ..utils import list_aggregate_events 6 | from ..utils import list_concrete_aggregates 7 | from ..utils import _list_subclasses 8 | from ..utils import _list_internal_classes 9 | from unittest import mock 10 | import pytest 11 | 12 | 13 | def test_list_subclasses(): 14 | class Parent: 15 | pass 16 | 17 | class Child(Parent): 18 | pass 19 | 20 | class GrandChild(Child): 21 | pass 22 | 23 | assert set(_list_subclasses(Parent)) == {Child, GrandChild} 24 | 25 | 26 | def test_list_subclasses_when_none(): 27 | class Parent: 28 | pass 29 | 30 | assert _list_subclasses(Parent) == [] 31 | 32 | 33 | def list_internal_classes(): 34 | class Parent: 35 | class Child1: 36 | pass 37 | 38 | class Child2: 39 | class GrandChild: 40 | pass 41 | 42 | # Please note that GrandChild is not on the list! 43 | assert set(_list_internal_classes(Parent)) == {Parent.Child1, Parent.Child2} 44 | 45 | 46 | def list_internal_classes_none(): 47 | class Parent: 48 | pass 49 | 50 | assert _list_internal_classes(Parent) == [] 51 | 52 | 53 | def test_list_aggregates_skip_abstract(): 54 | class Aggregate1(BaseAggregate): 55 | pass 56 | 57 | @abstract 58 | class Aggregate2(BaseAggregate): 59 | pass 60 | 61 | with mock.patch('djangoevents.utils._list_subclasses') as list_subclasses: 62 | list_subclasses.return_value = [Aggregate1, Aggregate2] 63 | aggregates = list_concrete_aggregates() 64 | assert aggregates == [Aggregate1] 65 | 66 | 67 | def test_list_aggregates_none_present(): 68 | with mock.patch('djangoevents.utils._list_subclasses') as list_subclasses: 69 | list_subclasses.return_value = [] 70 | aggregates = list_concrete_aggregates() 71 | assert aggregates == [] 72 | 73 | 74 | def test_list_events_sample_event_appart_from_abstract(): 75 | class Aggregate(BaseAggregate): 76 | class Evt1(DomainEvent): 77 | def mutate_event(self, *args, **kwargs): 78 | pass 79 | 80 | class Evt2(DomainEvent): 81 | def mutate_event(self, *args, **kwargs): 82 | pass 83 | 84 | class Evt3(DomainEvent): 85 | # No mutate_event present 86 | pass 87 | 88 | @abstract 89 | class Evt4(DomainEvent): 90 | def mutate_event(self, *args, **kwargs): 91 | pass 92 | 93 | events = list_aggregate_events(Aggregate) 94 | assert set(events) == {Aggregate.Evt1, Aggregate.Evt2} 95 | 96 | 97 | def test_list_events_not_an_aggregate(): 98 | events = list_aggregate_events(list) 99 | assert events == [] 100 | 101 | 102 | @pytest.mark.parametrize('name, expected_output', [ 103 | ('UserRegistered', 'user_registered'), 104 | ('UserRegisteredWithEmail', 'user_registered_with_email'), 105 | ('HttpResponse', 'http_response'), 106 | ('HTTPResponse', 'http_response'), 107 | ('already_snake', 'already_snake'), 108 | ]) 109 | def test_camel_case_to_snake_case(name, expected_output): 110 | assert expected_output == camel_case_to_snake_case(name) 111 | -------------------------------------------------------------------------------- /djangoevents/tests/test_utils_abstract.py: -------------------------------------------------------------------------------- 1 | from djangoevents.domain import BaseAggregate 2 | from djangoevents.domain import DomainEvent 3 | from djangoevents.utils_abstract import abstract 4 | from djangoevents.utils_abstract import is_abstract 5 | 6 | 7 | def test_subclass_of_abstract_event_is_not_abstract(): 8 | 9 | class Aggregate(BaseAggregate): 10 | 11 | @abstract 12 | class Event1(DomainEvent): 13 | pass 14 | 15 | class Event2(Event1): 16 | pass 17 | 18 | assert is_abstract(Aggregate.Event1) 19 | assert not is_abstract(Aggregate.Event2) 20 | -------------------------------------------------------------------------------- /djangoevents/unifiedtranscoder.py: -------------------------------------------------------------------------------- 1 | from .domain import DomainEvent 2 | from .schema import get_event_version 3 | from .settings import adds_schema_version_to_event_data 4 | from .utils import camel_case_to_snake_case 5 | from collections import namedtuple 6 | from datetime import datetime 7 | from eventsourcing.domain.model.events import resolve_attr 8 | from eventsourcing.domain.services.transcoding import AbstractTranscoder 9 | from eventsourcing.domain.services.transcoding import ObjectJSONDecoder 10 | from eventsourcing.domain.services.transcoding import id_prefix_from_event 11 | from eventsourcing.domain.services.transcoding import make_stored_entity_id 12 | from eventsourcing.utils.time import timestamp_from_uuid 13 | from inspect import isclass 14 | 15 | import importlib 16 | import json 17 | 18 | 19 | UnifiedStoredEvent = namedtuple('UnifiedStoredEvent', [ 20 | 'event_id', 21 | 'event_type', 22 | 'event_version', 23 | 'event_data', 24 | 'aggregate_id', 25 | 'aggregate_type', 26 | 'aggregate_version', 27 | 'create_date', 28 | 'metadata', 29 | 'module_name', 30 | 'class_name', 31 | 'stored_entity_id', 32 | ]) 33 | 34 | 35 | class UnifiedTranscoder(AbstractTranscoder): 36 | def __init__(self, json_encoder_cls=None): 37 | self.json_encoder_cls = json_encoder_cls 38 | # encrypt not implemented 39 | 40 | def serialize(self, domain_event): 41 | """ 42 | Serializes a domain event into a stored event. 43 | """ 44 | assert isinstance(domain_event, DomainEvent) 45 | 46 | event_data = {key: value for key, value in domain_event.__dict__.items() if key not in { 47 | 'domain_event_id', 48 | 'entity_id', 49 | 'entity_version', 50 | 'metadata', 51 | }} 52 | 53 | domain_event_class = type(domain_event) 54 | event_version = get_event_version(domain_event_class) 55 | 56 | return UnifiedStoredEvent( 57 | event_id=domain_event.domain_event_id, 58 | event_type=get_event_type(domain_event), 59 | event_version=event_version, 60 | event_data=self._json_encode(event_data), 61 | aggregate_id=domain_event.entity_id, 62 | aggregate_type=get_aggregate_type(domain_event), 63 | aggregate_version=domain_event.entity_version, 64 | create_date=datetime.fromtimestamp(timestamp_from_uuid(domain_event.domain_event_id)), 65 | metadata=self._json_encode(getattr(domain_event, 'metadata', None)), 66 | module_name=domain_event_class.__module__, 67 | class_name=domain_event_class.__qualname__, 68 | # have to have stored_entity_id because of the lib 69 | stored_entity_id=make_stored_entity_id(id_prefix_from_event(domain_event), domain_event.entity_id), 70 | ) 71 | 72 | def deserialize(self, stored_event): 73 | """ 74 | Recreates original domain event from stored event topic and event attrs. 75 | """ 76 | assert isinstance(stored_event, UnifiedStoredEvent) 77 | # Get the domain event class from the topic. 78 | domain_event_class = self._get_domain_event_class(stored_event.module_name, stored_event.class_name) 79 | 80 | # Deserialize event attributes from JSON 81 | event_attrs = self._json_decode(stored_event.event_data) 82 | 83 | # Reinstantiate and return the domain event object. 84 | defaults = { 85 | 'entity_id': stored_event.aggregate_id, 86 | 'entity_version': stored_event.aggregate_version, 87 | 'domain_event_id': stored_event.event_id, 88 | } 89 | 90 | if adds_schema_version_to_event_data(): 91 | defaults['schema_version'] = None 92 | 93 | kwargs = {**defaults, **event_attrs} 94 | 95 | try: 96 | domain_event = domain_event_class(**kwargs) 97 | except TypeError: 98 | raise ValueError("Unable to instantiate class '{}' with data '{}'" 99 | "".format(stored_event.class_name, event_attrs)) 100 | 101 | return domain_event 102 | 103 | @staticmethod 104 | def _get_domain_event_class(module_name, class_name): 105 | """Return domain class described by given topic. 106 | 107 | Args: 108 | module_name: string of module_name 109 | class_name: string of class_name 110 | Returns: 111 | A domain class. 112 | 113 | Raises: 114 | ResolveDomainFailed: If there is no such domain class. 115 | """ 116 | try: 117 | module = importlib.import_module(module_name) 118 | except ImportError as e: 119 | raise ResolveDomainFailed("{}#{}: {}".format(module_name, class_name, e)) 120 | try: 121 | domain_event_class = resolve_attr(module, class_name) 122 | except AttributeError as e: 123 | raise ResolveDomainFailed("{}#{}: {}".format(module_name, class_name, e)) 124 | 125 | if not isclass(domain_event_class): 126 | raise ValueError("Event class is not a type: {}".format(domain_event_class)) 127 | 128 | if not issubclass(domain_event_class, DomainEvent): 129 | raise ValueError("Event class is not a DomainEvent: {}".format(domain_event_class)) 130 | 131 | return domain_event_class 132 | 133 | def _json_encode(self, data): 134 | return json.dumps(data, separators=(',', ':'), sort_keys=True, cls=self.json_encoder_cls) 135 | 136 | def _json_decode(self, json_str): 137 | return json.loads(json_str, cls=ObjectJSONDecoder) 138 | 139 | 140 | class ResolveDomainFailed(Exception): 141 | pass 142 | 143 | 144 | def get_aggregate_type(domain_event): 145 | assert isinstance(domain_event, DomainEvent) 146 | domain_event_class = type(domain_event) 147 | return domain_event_class.__qualname__.split('.')[0] 148 | 149 | 150 | def get_event_type(domain_event): 151 | if hasattr(domain_event, 'event_type'): 152 | return getattr(domain_event, 'event_type') 153 | else: 154 | aggregate_type = get_aggregate_type(domain_event) 155 | event_name = domain_event.__class__.__name__ 156 | event_type_name = '%s%s' % (aggregate_type, event_name) 157 | return camel_case_to_snake_case(event_type_name) 158 | -------------------------------------------------------------------------------- /djangoevents/utils.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from .domain import BaseEntity 3 | from .domain import BaseAggregate 4 | from .domain import DomainEvent 5 | from djangoevents.utils_abstract import is_abstract 6 | import re 7 | 8 | 9 | def list_concrete_aggregates(): 10 | """ 11 | Lists all non abstract aggregates defined within the application. 12 | """ 13 | aggregates = set(_list_subclasses(BaseAggregate) + _list_subclasses(BaseEntity)) 14 | return [aggregate for aggregate in aggregates if not aggregate.is_abstract_class()] 15 | 16 | 17 | def is_event_mutating(event): 18 | return hasattr(event, 'mutate_event') 19 | 20 | 21 | def list_aggregate_events(aggregate_cls): 22 | """ 23 | Lists all aggregate_cls events defined within the application. 24 | Note: Only events with a defined `mutate_event` flow and are not marked as abstract will be returned. 25 | """ 26 | events = _list_internal_classes(aggregate_cls, DomainEvent) 27 | return [event_cls for event_cls in events if is_event_mutating(event_cls) and not is_abstract(event_cls)] 28 | 29 | 30 | def event_to_json(event): 31 | """ 32 | Converts an event class to its dictionary representation. 33 | Underlying eventsourcing library does not provide a proper event->dict conversion function. 34 | 35 | Note: Similarly to event journal persistence flow, this method supports native JSON types only. 36 | """ 37 | return vars(event) 38 | 39 | 40 | def _list_subclasses(cls): 41 | """ 42 | Recursively lists all subclasses of `cls`. 43 | """ 44 | subclasses = cls.__subclasses__() 45 | 46 | for subclass in cls.__subclasses__(): 47 | subclasses += _list_subclasses(subclass) 48 | 49 | return subclasses 50 | 51 | 52 | def _list_internal_classes(cls, base_class=None): 53 | base_class = base_class or object 54 | 55 | return [cls_attribute for cls_attribute in cls.__dict__.values() 56 | if inspect.isclass(cls_attribute) 57 | and issubclass(cls_attribute, base_class)] 58 | 59 | 60 | def camel_case_to_snake_case(text): 61 | 62 | def repl(match): 63 | sep = match.group().lower() 64 | if match.start() > 0: 65 | sep = '_%s' % sep 66 | return sep 67 | 68 | return re.sub(r'[A-Z][a-z]', repl, text).lower() 69 | -------------------------------------------------------------------------------- /djangoevents/utils_abstract.py: -------------------------------------------------------------------------------- 1 | _abstract_classes = set() 2 | 3 | 4 | def abstract(cls): 5 | """ 6 | Decorator marking classes as abstract. 7 | 8 | The "abstract" mark is an internal tag. Classes can be checked for 9 | being abstract with the `is_abstract` function. The tag is non 10 | inheritable: every class or subclass has to be explicitly marked 11 | with the decorator to be considered abstract. 12 | """ 13 | 14 | _abstract_classes.add(cls) 15 | return cls 16 | 17 | 18 | def is_abstract(cls): 19 | return cls in _abstract_classes 20 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=1.10,<1.11 2 | djangorestframework 3 | pytest-django 4 | pytest-watch 5 | ipython 6 | ipdb 7 | factory-boy==2.7.0 8 | eventsourcing==1.2.1 -------------------------------------------------------------------------------- /scripts/makemigrations.sh: -------------------------------------------------------------------------------- 1 | # run this from repository's root directory 2 | DJANGO_SETTINGS_MODULE=djangoevents.tests.settings.settings_test django-admin makemigrations 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | from setuptools.command.upload import upload 3 | import os 4 | 5 | 6 | class ReleaseToPyPICommand(upload): 7 | 8 | def finalize_options(self): 9 | self.repository = 'https://upload.pypi.org/legacy/' 10 | self.username = os.environ['PYPI_USERNAME'] 11 | self.password = os.environ['PYPI_PASSWORD'] 12 | 13 | 14 | setup( 15 | name='djangoevents', 16 | version='0.14.1', 17 | url='https://github.com/ApplauseOSS/djangoevents', 18 | license='MIT', 19 | description='Building blocks for building Event Sourcing Django applications.', 20 | author='Applause', 21 | author_email='eng@applause.com', 22 | zip_safe=False, 23 | packages=[ 24 | 'djangoevents', 25 | 'djangoevents.migrations', 26 | 'djangoevents.tests.settings', 27 | ], 28 | include_package_data=True, 29 | install_requires=[ 30 | 'eventsourcing>=1.2,<1.3', 31 | 'django', 32 | 'avro-python3==1.7.7', 33 | 'stringcase==1.0.6', 34 | ], 35 | cmdclass={ 36 | 'release_to_pypi': ReleaseToPyPICommand 37 | } 38 | ) 39 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py35 4 | py36 5 | 6 | [travis:env] 7 | DJANGO = 8 | 1.10: django110 9 | 1.11: django111 10 | master: djangomaster 11 | 12 | [testenv] 13 | commands = pytest 14 | setenv = 15 | PYTHONDONTWRITEBYTECODE=1 16 | PYTHONWARNINGS=once 17 | passenv = 18 | DJANGO_SETTINGS_MODULE 19 | usedevelop = True 20 | deps = 21 | django110: Django>=1.10,<1.11 22 | django111: Django>=1.11a1,<2.0 23 | djangomaster: https://github.com/django/django/archive/master.tar.gz 24 | -rrequirements.txt 25 | 26 | [pytest] 27 | DJANGO_SETTINGS_MODULE = djangoevents.tests.settings.settings_test 28 | addopts = --tb short --nomigrations 29 | python_files = test*.py 30 | testpaths = djangoevents 31 | --------------------------------------------------------------------------------