├── entity_event ├── tests │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── templates │ │ ├── test_template.txt │ │ └── test_template.html │ ├── models.py │ ├── admin_tests.py │ ├── context_serializer_tests.py │ ├── context_loader_tests.py │ └── model_tests.py ├── migrations │ ├── __init__.py │ ├── 0003_auto_20170830_1321.py │ ├── 0002_medium_creation_time.py │ ├── 0004_auto_20180403_1655.py │ ├── 0005_auto_20200409_1612.py │ ├── 0001_initial.py │ └── 0001_0005_squashed.py ├── version.py ├── urls.py ├── __init__.py ├── apps.py ├── admin.py ├── context_serializer.py └── context_loader.py ├── CONTRIBUTORS.txt ├── README.rst ├── requirements ├── docs.txt ├── requirements.txt └── requirements-testing.txt ├── MANIFEST.in ├── .readthedocs.yaml ├── setup.cfg ├── .coveragerc ├── publish.py ├── .gitignore ├── docs ├── index.rst ├── installation.rst ├── ref │ └── entity_event.rst ├── contributing.rst ├── release_notes.rst ├── Makefile ├── conf.py ├── advanced_features.rst └── quickstart.rst ├── manage.py ├── run_tests.py ├── LICENSE ├── setup.py ├── .github └── workflows │ └── tests.yml └── settings.py /entity_event/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /entity_event/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /entity_event/tests/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /entity_event/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '3.1.2' 2 | -------------------------------------------------------------------------------- /CONTRIBUTORS.txt: -------------------------------------------------------------------------------- 1 | Erik Swanson (erik.swanson@ambition.com) 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | THIS PROJECT HAS BEEN DEPRECATED! DO NOT USE! 2 | -------------------------------------------------------------------------------- /requirements/docs.txt: -------------------------------------------------------------------------------- 1 | Sphinx>=1.2.2 2 | sphinx_rtd_theme 3 | -------------------------------------------------------------------------------- /entity_event/urls.py: -------------------------------------------------------------------------------- 1 | 2 | urlpatterns = [] # pragma: no cover 3 | -------------------------------------------------------------------------------- /entity_event/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .version import __version__ 3 | -------------------------------------------------------------------------------- /requirements/requirements.txt: -------------------------------------------------------------------------------- 1 | cached-property>=1.3.1 2 | django-entity>=6.1.0 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | recursive-include requirements * 4 | -------------------------------------------------------------------------------- /entity_event/tests/templates/test_template.txt: -------------------------------------------------------------------------------- 1 | Test text template with value {{ fk_model.value }} -------------------------------------------------------------------------------- /requirements/requirements-testing.txt: -------------------------------------------------------------------------------- 1 | coverage 2 | django-dynamic-fixture 3 | django-nose 4 | flake8 5 | freezegun 6 | mock 7 | psycopg2 8 | -------------------------------------------------------------------------------- /entity_event/tests/templates/test_template.html: -------------------------------------------------------------------------------- 1 | Test html template with value {% if suppress_value != True %}{{ fk_model.value }}{% else %}suppressed{% endif %} -------------------------------------------------------------------------------- /entity_event/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class EntityEventConfig(AppConfig): 5 | name = 'entity_event' 6 | verbose_name = 'Django Entity Event' 7 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.8" 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | python: 12 | install: 13 | - requirements: requirements/docs.txt 14 | - requirements: requirements/requirements.txt -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | exclude = docs,venv,env,*.egg,migrations,south_migrations 4 | max-complexity = 10 5 | ignore = E402, E722, W504 6 | 7 | [build_sphinx] 8 | source-dir = docs/ 9 | build-dir = docs/_build 10 | all_files = 1 11 | 12 | [bdist_wheel] 13 | universal = 1 14 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | omit = 4 | entity_event/migrations/* 5 | entity_event/version.py 6 | source = entity_event 7 | [report] 8 | exclude_lines = 9 | # Have to re-enable the standard pragma 10 | pragma: no cover 11 | 12 | # Don't complain if tests don't hit defensive assertion code: 13 | raise NotImplementedError 14 | fail_under = 100 15 | show_missing = 1 16 | -------------------------------------------------------------------------------- /publish.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | subprocess.call(['rm', '-r', 'dist/']) 4 | subprocess.call(['pip', 'install', 'wheel']) 5 | subprocess.call(['pip', 'install', 'twine']) 6 | subprocess.call(['python', 'setup.py', 'clean', '--all']) 7 | subprocess.call(['python', 'setup.py', 'register', 'sdist', 'bdist_wheel']) 8 | subprocess.call(['twine', 'upload', 'dist/*']) 9 | subprocess.call(['rm', '-r', 'dist/']) 10 | subprocess.call(['rm', '-r', 'build/']) 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled python files 2 | *.pyc 3 | 4 | # Vim files 5 | *.swp 6 | *.swo 7 | 8 | # Coverage files 9 | .coverage 10 | htmlcov/ 11 | 12 | # Setuptools distribution folder. 13 | /dist/ 14 | /build/ 15 | 16 | # Python egg metadata, regenerated from source files by setuptools. 17 | /*.egg-info 18 | /*.egg 19 | .eggs/ 20 | 21 | # Virtualenv 22 | env/ 23 | venv/ 24 | 25 | # OSX 26 | .DS_Store 27 | 28 | # Pycharm 29 | .idea/ 30 | 31 | # Documentation artifacts 32 | docs/_build/ 33 | 34 | # IPython Notebook 35 | .ipynb_checkpoints/ 36 | -------------------------------------------------------------------------------- /entity_event/migrations/0003_auto_20170830_1321.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.4 on 2017-08-30 13:21 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('entity_event', '0002_medium_creation_time'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='event', 16 | name='uuid', 17 | field=models.CharField(max_length=512, unique=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /entity_event/migrations/0002_medium_creation_time.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.11 on 2017-05-24 18:07 3 | 4 | import datetime 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('entity_event', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='medium', 17 | name='time_created', 18 | field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2017, 5, 24, 18, 7, 45, 987701)), 19 | preserve_default=False, 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Django Entity Event 2 | =================== 3 | 4 | Django Entity Event is a framework for storing and rendering events, managing users 5 | subscriptions to those events, and providing clean ways to make 6 | notifying users as easy as possible. It builds on the `Django 7 | Entity's`_ powerful method of unifying individuals and groups into a 8 | consistent framework. 9 | 10 | .. _Django Entity's: https://github.com/ambitioninc/django-entity/ 11 | 12 | Table of Contents 13 | ----------------- 14 | 15 | .. toctree:: 16 | :maxdepth: 2 17 | 18 | installation 19 | quickstart 20 | advanced_features 21 | ref/entity_event 22 | contributing 23 | release_notes 24 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | 4 | # Show warnings about django deprecations - uncomment for version upgrade testing 5 | import warnings 6 | from django.utils.deprecation import RemovedInNextVersionWarning 7 | warnings.filterwarnings('always', category=DeprecationWarning) 8 | warnings.filterwarnings('always', category=PendingDeprecationWarning) 9 | warnings.filterwarnings('always', category=RemovedInNextVersionWarning) 10 | 11 | from settings import configure_settings 12 | 13 | 14 | if __name__ == '__main__': 15 | configure_settings() 16 | 17 | from django.core.management import execute_from_command_line 18 | 19 | execute_from_command_line(sys.argv) 20 | -------------------------------------------------------------------------------- /entity_event/tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class TestFKModel(models.Model): 5 | # tell nose to ignore 6 | __test__ = False 7 | 8 | value = models.CharField(max_length=64) 9 | 10 | 11 | class TestFKModel2(models.Model): 12 | # tell nose to ignore 13 | __test__ = False 14 | 15 | value = models.CharField(max_length=64) 16 | 17 | 18 | class TestModel(models.Model): 19 | # tell nose to ignore 20 | __test__ = False 21 | 22 | value = models.CharField(max_length=64) 23 | fk = models.ForeignKey(TestFKModel, on_delete=models.CASCADE) 24 | fk2 = models.ForeignKey(TestFKModel2, on_delete=models.CASCADE) 25 | fk_m2m = models.ManyToManyField(TestFKModel, related_name='+') 26 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Django Entity Event is compatible with Python versions 2.7, 3.3, and 5 | 3.4. 6 | 7 | Installation with Pip 8 | --------------------- 9 | 10 | Entity Event is available on PyPi. It can be installed using ``pip``:: 11 | 12 | pip install django-entity-event 13 | 14 | Use with Django 15 | --------------- 16 | 17 | To use Entity Event with django, first be sure to install it and/or 18 | include it in your ``requirements.txt`` Then include 19 | ``'entity_event'`` in ``settings.INSTALLED_APPS``. After it is 20 | included in your installed apps, run:: 21 | 22 | ./manage.py migrate entity_event 23 | 24 | if you are using South_. Otherwise run:: 25 | 26 | ./manage.py syncdb 27 | 28 | .. _South: http://south.aeracode.org/ 29 | 30 | -------------------------------------------------------------------------------- /run_tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides the ability to run test on a standalone Django app. 3 | """ 4 | import sys 5 | from optparse import OptionParser 6 | from settings import configure_settings 7 | 8 | 9 | # Configure the default settings 10 | configure_settings() 11 | 12 | # Django nose must be imported here since it depends on the settings being configured 13 | from django_nose import NoseTestSuiteRunner 14 | 15 | 16 | def run(*test_args, **kwargs): 17 | if not test_args: 18 | test_args = ['entity_event'] 19 | 20 | kwargs.setdefault('interactive', False) 21 | 22 | test_runner = NoseTestSuiteRunner(**kwargs) 23 | 24 | failures = test_runner.run_tests(test_args) 25 | sys.exit(failures) 26 | 27 | 28 | if __name__ == '__main__': 29 | parser = OptionParser() 30 | parser.add_option('--verbosity', dest='verbosity', action='store', default=1, type=int) 31 | (options, args) = parser.parse_args() 32 | 33 | run(*args, **options.__dict__) 34 | -------------------------------------------------------------------------------- /entity_event/tests/admin_tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django_dynamic_fixture import G 3 | 4 | from entity_event.admin import AdminEventForm 5 | from entity_event.models import Source, Event 6 | 7 | 8 | class AdminEventFormSaveTest(TestCase): 9 | def setUp(self): 10 | source = G(Source) 11 | self.form_data = { 12 | 'source': source.id, 13 | 'text': 'test text', 14 | } 15 | 16 | def test_new_obj(self): 17 | form = AdminEventForm(self.form_data) 18 | form.is_valid() 19 | form.save() 20 | self.assertEqual(Event.objects.count(), 1) 21 | 22 | 23 | class AdminEventFormSaveM2MTest(TestCase): 24 | def setUp(self): 25 | source = G(Source) 26 | self.form_data = { 27 | 'source': source.id, 28 | 'text': 'test text', 29 | } 30 | 31 | def test_does_nothing(self): 32 | form = AdminEventForm(self.form_data) 33 | form.save_m2m() 34 | self.assertEqual(Event.objects.count(), 0) 35 | -------------------------------------------------------------------------------- /entity_event/migrations/0004_auto_20180403_1655.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.11 on 2018-04-03 16:55 3 | 4 | import django.core.serializers.json 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('entity_event', '0003_auto_20170830_1321'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='contextrenderer', 17 | name='context_hints', 18 | field=models.JSONField(default=None, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True), 19 | ), 20 | migrations.AlterField( 21 | model_name='event', 22 | name='context', 23 | field=models.JSONField(encoder=django.core.serializers.json.DjangoJSONEncoder), 24 | ), 25 | migrations.AlterField( 26 | model_name='medium', 27 | name='additional_context', 28 | field=models.JSONField(default=None, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Ambition 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /docs/ref/entity_event.rst: -------------------------------------------------------------------------------- 1 | .. _ref-entity_event: 2 | 3 | Code documentation 4 | ================== 5 | 6 | .. automodule:: entity_event.models 7 | 8 | .. autoclass:: Medium() 9 | 10 | .. automethod:: events(self, **event_filters) 11 | 12 | .. automethod:: entity_events(self, entity, **event_filters) 13 | 14 | .. automethod:: events_targets(self, entity_kind, **event_filters) 15 | 16 | .. automethod:: followed_by(self, entities) 17 | 18 | .. automethod:: followers_of(self, entities) 19 | 20 | .. automethod:: render(self, events) 21 | 22 | 23 | .. autoclass:: Source() 24 | 25 | .. autoclass:: SourceGroup() 26 | 27 | .. autoclass:: Unsubscription() 28 | 29 | .. autoclass:: Subscription() 30 | 31 | .. automethod:: subscribed_entities(self) 32 | 33 | .. autoclass:: EventQuerySet() 34 | 35 | .. automethod:: mark_seen(self, medium) 36 | 37 | .. autoclass:: EventManager() 38 | 39 | .. automethod:: create_event(self, source, context, uuid, time_expires, actors, ignore_duplicates) 40 | 41 | .. automethod:: mark_seen(self, medium) 42 | 43 | .. autoclass:: EventActor() 44 | 45 | .. autoclass:: EventSeen() 46 | 47 | .. autoclass:: RenderingStyle() 48 | 49 | .. autoclass:: ContextRenderer() -------------------------------------------------------------------------------- /entity_event/tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.db import models, migrations 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='TestFKModel', 15 | fields=[ 16 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 17 | ('value', models.CharField(max_length=64)), 18 | ], 19 | options={ 20 | }, 21 | bases=(models.Model,), 22 | ), 23 | migrations.CreateModel( 24 | name='TestFKModel2', 25 | fields=[ 26 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 27 | ('value', models.CharField(max_length=64)), 28 | ], 29 | options={ 30 | }, 31 | bases=(models.Model,), 32 | ), 33 | migrations.CreateModel( 34 | name='TestModel', 35 | fields=[ 36 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 37 | ('value', models.CharField(max_length=64)), 38 | ('fk', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tests.TestFKModel')), 39 | ('fk2', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tests.TestFKModel2')), 40 | ('fk_m2m', models.ManyToManyField(related_name='+', to='tests.TestFKModel')), 41 | ], 42 | options={ 43 | }, 44 | bases=(models.Model,), 45 | ), 46 | ] 47 | -------------------------------------------------------------------------------- /entity_event/migrations/0005_auto_20200409_1612.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.8 on 2020-04-09 16:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('entity_event', '0004_auto_20180403_1655'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='contextrenderer', 15 | name='name', 16 | field=models.CharField(max_length=256, unique=True), 17 | ), 18 | migrations.AlterField( 19 | model_name='medium', 20 | name='display_name', 21 | field=models.CharField(max_length=256), 22 | ), 23 | migrations.AlterField( 24 | model_name='medium', 25 | name='name', 26 | field=models.CharField(max_length=256, unique=True), 27 | ), 28 | migrations.AlterField( 29 | model_name='renderingstyle', 30 | name='display_name', 31 | field=models.CharField(default='', max_length=256), 32 | ), 33 | migrations.AlterField( 34 | model_name='renderingstyle', 35 | name='name', 36 | field=models.CharField(max_length=256, unique=True), 37 | ), 38 | migrations.AlterField( 39 | model_name='source', 40 | name='display_name', 41 | field=models.CharField(max_length=256), 42 | ), 43 | migrations.AlterField( 44 | model_name='source', 45 | name='name', 46 | field=models.CharField(max_length=256, unique=True), 47 | ), 48 | migrations.AlterField( 49 | model_name='sourcegroup', 50 | name='display_name', 51 | field=models.CharField(max_length=256), 52 | ), 53 | migrations.AlterField( 54 | model_name='sourcegroup', 55 | name='name', 56 | field=models.CharField(max_length=256, unique=True), 57 | ), 58 | ] 59 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | from setuptools import setup, find_packages 3 | 4 | # import multiprocessing to avoid this bug (http://bugs.python.org/issue15881#msg170215) 5 | import multiprocessing 6 | assert multiprocessing 7 | 8 | 9 | def get_version(): 10 | """ 11 | Extracts the version number from the version.py file. 12 | """ 13 | VERSION_FILE = 'entity_event/version.py' 14 | mo = re.search(r'^__version__ = [\'"]([^\'"]*)[\'"]', open(VERSION_FILE, 'rt').read(), re.M) 15 | if mo: 16 | return mo.group(1) 17 | else: 18 | raise RuntimeError('Unable to find version string in {0}.'.format(VERSION_FILE)) 19 | 20 | 21 | def get_lines(file_path): 22 | return open(file_path, 'r').read().split('\n') 23 | 24 | 25 | install_requires = get_lines('requirements/requirements.txt') 26 | tests_require = get_lines('requirements/requirements-testing.txt') 27 | 28 | 29 | setup( 30 | name='django-entity-event', 31 | version=get_version(), 32 | description='Newsfeed-style event tracking and subscription management for django-entity.', 33 | long_description=open('README.rst').read(), 34 | url='https://github.com/ambitioninc/django-entity-event', 35 | author='Erik Swanson', 36 | author_email='opensource@ambition.com', 37 | keywords='', 38 | packages=find_packages(), 39 | classifiers=[ 40 | 'Programming Language :: Python', 41 | 'Programming Language :: Python :: 3.7', 42 | 'Programming Language :: Python :: 3.8', 43 | 'Programming Language :: Python :: 3.9', 44 | 'Intended Audience :: Developers', 45 | 'License :: OSI Approved :: MIT License', 46 | 'Operating System :: OS Independent', 47 | 'Framework :: Django', 48 | 'Framework :: Django :: 3.2', 49 | 'Framework :: Django :: 4.0', 50 | 'Framework :: Django :: 4.1', 51 | 'Framework :: Django :: 4.2', 52 | ], 53 | license='MIT', 54 | install_requires=install_requires, 55 | tests_require=tests_require, 56 | test_suite='run_tests.run', 57 | include_package_data=True, 58 | zip_safe=False, 59 | ) 60 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Contributions and issues are most welcome! All issues and pull requests are 5 | handled through github on the `ambitioninc repository`_. Also, please check for 6 | any existing issues before filing a new one. If you have a great idea but it 7 | involves big changes, please file a ticket before making a pull request! We 8 | want to make sure you don't spend your time coding something that might not fit 9 | the scope of the project. 10 | 11 | .. _ambitioninc repository: https://github.com/ambitioninc/django-entity-event/issues 12 | 13 | Running the tests 14 | ----------------- 15 | 16 | To get the source source code and run the unit tests, run:: 17 | 18 | $ git clone git://github.com/ambitioninc/django-entity-event.git 19 | $ cd django-entity-event 20 | $ virtualenv env 21 | $ . env/bin/activate 22 | $ python setup.py install 23 | $ coverage run setup.py test 24 | $ coverage report --fail-under=100 25 | 26 | While 100% code coverage does not make a library bug-free, it significantly 27 | reduces the number of easily caught bugs! Please make sure coverage is at 100% 28 | before submitting a pull request! 29 | 30 | Code Quality 31 | ------------ 32 | 33 | For code quality, please run flake8:: 34 | 35 | $ pip install flake8 36 | $ flake8 . 37 | 38 | Code Styling 39 | ------------ 40 | Please arrange imports with the following style 41 | 42 | .. code-block:: python 43 | 44 | # Standard library imports 45 | import os 46 | 47 | # Third party package imports 48 | from mock import patch 49 | from django.conf import settings 50 | 51 | # Local package imports 52 | from entity_event.version import __version__ 53 | 54 | Please follow `Google's python style`_ guide wherever possible. 55 | 56 | .. _Google's python style: http://google-styleguide.googlecode.com/svn/trunk/pyguide.html 57 | 58 | Building the docs 59 | ----------------- 60 | 61 | When in the project directory:: 62 | 63 | pip install -r requirements/docs.txt 64 | python setup.py build_sphinx 65 | open docs/_build/html/index.html 66 | 67 | Release Checklist 68 | ----------------- 69 | 70 | Before a new release, please go through the following checklist: 71 | 72 | * Bump version in entity_event/version.py 73 | * Add a release note in docs/release_notes.rst 74 | * Git tag the version 75 | * Upload to pypi:: 76 | 77 | pip install wheel 78 | python setup.py sdist bdist_wheel upload 79 | 80 | Vulnerability Reporting 81 | ----------------------- 82 | 83 | For any security issues, please do NOT file an issue or pull request on github! 84 | Please contact `security@ambition.com`_ with the GPG key provided on `Ambition's 85 | website`_. 86 | 87 | .. _security@ambition.com: mailto:security@ambition.com 88 | .. _Ambition's website: http://ambition.com/security/ 89 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # copied from django-cte 2 | name: entity_event tests 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master,develop] 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | python: ['3.7', '3.8', '3.9'] 16 | django: 17 | - 'Django~=3.2.0' 18 | - 'Django~=4.0.0' 19 | - 'Django~=4.1.0' 20 | - 'Django~=4.2.0' 21 | psycopg: 22 | - 'psycopg2==2.9.6' 23 | - 'psycopg==3.1.10' 24 | experimental: [false] 25 | # include: 26 | # - python: '3.9' 27 | # django: 'https://github.com/django/django/archive/refs/heads/main.zip#egg=Django' 28 | # experimental: true 29 | # # NOTE this job will appear to pass even when it fails because of 30 | # # `continue-on-error: true`. Github Actions apparently does not 31 | # # have this feature, similar to Travis' allow-failure, yet. 32 | # # https://github.com/actions/toolkit/issues/399 33 | 34 | exclude: 35 | - python: '3.7' 36 | django: 'Django~=4.0.0' 37 | - python: '3.7' 38 | django: 'Django~=4.1.0' 39 | - python: '3.7' 40 | django: 'Django~=4.2.0' 41 | - psycopg: 'psycopg==3.1.10' 42 | django: 'Django~=3.2.0' 43 | - psycopg: 'psycopg==3.1.10' 44 | django: 'Django~=4.0.0' 45 | - psycopg: 'psycopg==3.1.10' 46 | django: 'Django~=4.1.0' 47 | services: 48 | postgres: 49 | image: postgres:latest 50 | env: 51 | POSTGRES_DB: postgres 52 | POSTGRES_PASSWORD: postgres 53 | POSTGRES_USER: postgres 54 | ports: 55 | - 5432:5432 56 | options: >- 57 | --health-cmd pg_isready 58 | --health-interval 10s 59 | --health-timeout 5s 60 | --health-retries 5 61 | steps: 62 | - uses: actions/checkout@v3 63 | - uses: actions/setup-python@v3 64 | with: 65 | python-version: ${{ matrix.python }} 66 | - name: Setup 67 | run: | 68 | python --version 69 | pip install --upgrade pip wheel 70 | pip install -r requirements/requirements.txt 71 | pip install -r requirements/requirements-testing.txt 72 | pip install "${{ matrix.django }}" 73 | pip install "${{ matrix.psycopg }}" 74 | pip freeze 75 | - name: Run tests 76 | env: 77 | DB_SETTINGS: >- 78 | { 79 | "ENGINE":"django.db.backends.postgresql", 80 | "NAME":"entity_event", 81 | "USER":"postgres", 82 | "PASSWORD":"postgres", 83 | "HOST":"localhost", 84 | "PORT":"5432" 85 | } 86 | run: | 87 | coverage run manage.py test entity_event 88 | coverage report --fail-under=99 89 | continue-on-error: ${{ matrix.experimental }} 90 | - name: Check style 91 | run: flake8 entity_event 92 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | 4 | from django.conf import settings 5 | 6 | 7 | def configure_settings(): 8 | """ 9 | Configures settings for manage.py and for run_tests.py. 10 | """ 11 | if not settings.configured: 12 | # Determine the database settings depending on if a test_db var is set in CI mode or not 13 | test_db = os.environ.get('DB', None) 14 | if test_db is None: 15 | db_config = { 16 | 'ENGINE': 'django.db.backends.postgresql', 17 | 'NAME': 'entity', 18 | 'USER': 'postgres', 19 | 'PASSWORD': '', 20 | 'HOST': 'db', 21 | } 22 | elif test_db == 'postgres': 23 | db_config = { 24 | 'ENGINE': 'django.db.backends.postgresql', 25 | 'NAME': 'entity', 26 | 'USER': 'postgres', 27 | 'PASSWORD': '', 28 | 'HOST': 'db', 29 | } 30 | else: 31 | raise RuntimeError('Unsupported test DB {0}'.format(test_db)) 32 | 33 | # Check env for db override (used for github actions) 34 | if os.environ.get('DB_SETTINGS'): 35 | db_config = json.loads(os.environ.get('DB_SETTINGS')) 36 | 37 | settings.configure( 38 | TEST_RUNNER='django_nose.NoseTestSuiteRunner', 39 | SECRET_KEY='*', 40 | DEFAULT_AUTO_FIELD='django.db.models.AutoField', 41 | NOSE_ARGS=['--nocapture', '--nologcapture', '--verbosity=1'], 42 | DATABASES={ 43 | 'default': db_config, 44 | }, 45 | INSTALLED_APPS=( 46 | 'django.contrib.auth', 47 | 'django.contrib.contenttypes', 48 | 'django.contrib.sessions', 49 | 'django.contrib.messages', 50 | 'django.contrib.admin', 51 | 'entity', 52 | 'entity_event', 53 | 'entity_event.tests', 54 | 'django_nose', 55 | ), 56 | ROOT_URLCONF='entity_event.urls', 57 | DEBUG=False, 58 | MIDDLEWARE=( 59 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 60 | 'django.contrib.messages.middleware.MessageMiddleware', 61 | 'django.contrib.sessions.middleware.SessionMiddleware' 62 | ), 63 | TEMPLATES=[ 64 | { 65 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 66 | 'APP_DIRS': True, 67 | 'OPTIONS': { 68 | 'context_processors': [ 69 | 'django.template.context_processors.debug', 70 | 'django.template.context_processors.request', 71 | 'django.contrib.auth.context_processors.auth', 72 | 'django.contrib.messages.context_processors.messages', 73 | ], 74 | } 75 | } 76 | ], 77 | ) 78 | -------------------------------------------------------------------------------- /docs/release_notes.rst: -------------------------------------------------------------------------------- 1 | Release Notes 2 | ============= 3 | 4 | v3.1.2 5 | ------ 6 | * Read the Docs config file v2 7 | * Github actions for testing psycopg3 8 | 9 | v3.1.1 10 | ------ 11 | * drop django 2 12 | * add django 4.2 13 | 14 | v3.0.0 15 | ------ 16 | * drop python 3.6 17 | * support python 3.8, 3.9 18 | * django 3.2, 4.0, 4.1 19 | 20 | v2.2.1 21 | ------ 22 | * Fix bad queryset check which would cause it to evaluate. Add explicit None check. 23 | 24 | v2.2.0 25 | ------ 26 | * More optimizations for unseen events 27 | * [!!!BREAKING!!!] Renamed `get_filtered_events_queries` to `get_filtered_events_queryset` on the `Medium` model and it now returns an `Event` queryset 28 | 29 | v2.1.0 30 | ------ 31 | * Optimize querying for unseen events 32 | 33 | v2.0.0 34 | ------ 35 | * Optimize a few querysets 36 | * Add support for django 3.0, 3.1 37 | 38 | v1.3.0 39 | ------ 40 | * Increase model field lengths 41 | * Remove support for django 2.0, 2.1 42 | 43 | v1.2.0 44 | ------ 45 | * Python 3.7 46 | * Django 2.1 47 | * Django 2.2 48 | 49 | v1.1.0 50 | ------ 51 | * Use django's json field with encoder (drop 1.10) 52 | 53 | v1.0.0 54 | ------ 55 | * Add Django 2.0 support 56 | * Use tox for testing more versions 57 | * Bulk create events to save on queries 58 | 59 | 60 | v0.8.0 61 | ------ 62 | * Drop Django 1.8 support 63 | * Add Django 1.10 support 64 | * Add Django 1.11 support 65 | * Add python 3.6 support 66 | 67 | v0.7.1 68 | ------ 69 | * Increase the uuid length 70 | 71 | v0.7.0 72 | ------ 73 | * Add creation time for mediums so events can be queried per medium for after medium creation 74 | 75 | v0.6.0 76 | ------ 77 | * Add python 3.5 support, remove django 1.7 support 78 | 79 | v0.5.0 80 | ------ 81 | * Added django 1.9 support 82 | 83 | v0.4.4 84 | ------ 85 | * Added some optimizations during event fetching to select and prefetch some related objects 86 | 87 | v0.4.3 88 | ------ 89 | * Added ability to get a serialized version of an events context data 90 | 91 | v0.4.0 92 | ------ 93 | * Added 1.8 support and dropped 1.6 support for Django 94 | 95 | v0.3.4 96 | ------ 97 | * Fixed django-entity migration dependency for Django 1.6 98 | 99 | v0.3.3 100 | ------ 101 | * Added Django 1.7 compatibility and app config 102 | 103 | v0.3.2 104 | ------ 105 | * Added an additional_context field in the Medium object that allows passing of additional context to event renderings. 106 | * Added ability to define a default rendering style for all sources or source groups if a context renderer is not defined for a particular rendering style. 107 | 108 | v0.3.1 109 | ------ 110 | * Fixes a bug where contexts can have any numeric type as a pk 111 | 112 | v0.3.0 113 | ------ 114 | * Adds a template and context rendering system to entity event 115 | 116 | v0.2 117 | ---- 118 | * This release provides the core features of django-entity-event 119 | - Event Creation 120 | - Subscription Management 121 | - Event Querying 122 | - Admin Panel 123 | - Documentation 124 | 125 | v0.1 126 | ---- 127 | * This is the initial release of django-entity-event. 128 | -------------------------------------------------------------------------------- /entity_event/admin.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from uuid import uuid1 3 | 4 | from django import forms 5 | from django.contrib import admin 6 | 7 | from entity_event.models import ( 8 | AdminEvent, Event, EventActor, EventSeen, Medium, Source, SourceGroup, Subscription, Unsubscription 9 | ) 10 | 11 | 12 | class AdminEventForm(forms.ModelForm): 13 | source = forms.ModelChoiceField(queryset=Source.objects.all()) # initial = Source.objects.get(name='admin') 14 | text = forms.CharField(widget=forms.Textarea(attrs={'rows': '3', 'cols': '60'})) 15 | expires_date = forms.DateField(widget=forms.SelectDateWidget(), required=False) 16 | expires_time = forms.TimeField(label='Expires time (UTC 24 hr) E.g. 18:25', required=False) 17 | 18 | class Meta: 19 | model = AdminEvent 20 | fields = ['source', 'text', 'expires_date', 'expires_time'] 21 | 22 | def save(self, *args, **kwargs): 23 | self.clean() 24 | expires_date = self.cleaned_data['expires_date'] 25 | expires_time = self.cleaned_data['expires_time'] 26 | expires_datetime = ( 27 | datetime.combine(expires_date, expires_time) if expires_date and expires_time else datetime.max) 28 | context = {'text': self.cleaned_data['text']} 29 | event = Event.objects.create( 30 | source=self.cleaned_data['source'], 31 | context=context, 32 | time_expires=expires_datetime, 33 | uuid=uuid1().hex 34 | ) 35 | return event 36 | 37 | def save_m2m(self, *args, **kwargs): 38 | pass 39 | 40 | 41 | class AdminEventAdmin(admin.ModelAdmin): 42 | list_display = ('time', 'source') 43 | form = AdminEventForm 44 | 45 | 46 | class EventSeenAdmin(admin.ModelAdmin): 47 | list_display = ('event', 'medium', 'time_seen') 48 | list_filter = ('event__source',) 49 | 50 | 51 | class EventActorInline(admin.TabularInline): 52 | model = EventActor 53 | 54 | 55 | class EventSeenInline(admin.TabularInline): 56 | model = EventSeen 57 | 58 | 59 | class EventAdmin(admin.ModelAdmin): 60 | list_display = ('time', 'source') 61 | list_filter = ('time', 'source') 62 | 63 | inlines = [ 64 | EventActorInline, 65 | EventSeenInline 66 | ] 67 | 68 | 69 | class MediumAdmin(admin.ModelAdmin): 70 | list_display = ('display_name', 'description') 71 | 72 | 73 | class SourceAdmin(admin.ModelAdmin): 74 | list_display = ('display_name', 'description') 75 | 76 | 77 | class SourceGroupAdmin(admin.ModelAdmin): 78 | list_display = ('display_name', 'description') 79 | 80 | 81 | class SubscriptionAdmin(admin.ModelAdmin): 82 | list_display = ('entity', 'source', 'medium', 'sub_entity_kind', 'only_following') 83 | search_fields = ('entity', 'source') 84 | 85 | 86 | class UnsubscriptionAdmin(admin.ModelAdmin): 87 | list_display = ('entity', 'medium', 'source') 88 | 89 | 90 | admin.site.register(AdminEvent, AdminEventAdmin) 91 | admin.site.register(Medium, MediumAdmin) 92 | admin.site.register(Event, EventAdmin) 93 | admin.site.register(EventSeen, EventSeenAdmin) 94 | admin.site.register(Source, SourceAdmin) 95 | admin.site.register(SourceGroup, SourceGroupAdmin) 96 | admin.site.register(Subscription, SubscriptionAdmin) 97 | admin.site.register(Unsubscription, UnsubscriptionAdmin) 98 | -------------------------------------------------------------------------------- /entity_event/context_serializer.py: -------------------------------------------------------------------------------- 1 | import json 2 | from django.db import models 3 | from django.forms import model_to_dict 4 | 5 | 6 | class DefaultContextSerializer(object): 7 | """ 8 | Default class for serializing context data 9 | """ 10 | 11 | def __init__(self, context): 12 | super(DefaultContextSerializer, self).__init__() 13 | self.context = context 14 | 15 | @property 16 | def data(self): 17 | """ 18 | Data property that will return the serialized data 19 | :return: 20 | """ 21 | 22 | # Create a serialized context dict 23 | serialized_context = self.serialize_value(self.context) 24 | 25 | # Return the serialized context 26 | return serialized_context 27 | 28 | def serialize_value(self, value): 29 | """ 30 | Given a value, ensure that it is serialized properly 31 | :param value: 32 | :return: 33 | """ 34 | # Create a list of serialize methods to run the value through 35 | serialize_methods = [ 36 | self.serialize_model, 37 | self.serialize_json_string, 38 | self.serialize_list, 39 | self.serialize_dict 40 | ] 41 | 42 | # Run all of our serialize methods over our value 43 | for serialize_method in serialize_methods: 44 | value = serialize_method(value) 45 | 46 | # Return the serialized context value 47 | return value 48 | 49 | def serialize_model(self, value): 50 | """ 51 | Serializes a model and all of its prefetched foreign keys 52 | :param value: 53 | :return: 54 | """ 55 | 56 | # Check if the context value is a model 57 | if not isinstance(value, models.Model): 58 | return value 59 | 60 | # Serialize the model 61 | serialized_model = model_to_dict(value) 62 | 63 | # Check the model for cached foreign keys 64 | for model_field, model_value in serialized_model.items(): 65 | model_state = value._state 66 | 67 | # Django >= 2 68 | if hasattr(model_state, 'fields_cache'): # pragma: no cover 69 | if model_state.fields_cache.get(model_field): 70 | serialized_model[model_field] = model_state.fields_cache.get(model_field) 71 | else: # pragma: no cover 72 | # Django < 2 73 | cache_field = '_{0}_cache'.format(model_field) 74 | if hasattr(value, cache_field): 75 | serialized_model[model_field] = getattr(value, cache_field) 76 | 77 | # Return the serialized model 78 | return self.serialize_value(serialized_model) 79 | 80 | def serialize_json_string(self, value): 81 | """ 82 | Tries to load an encoded json string back into an object 83 | :param json_string: 84 | :return: 85 | """ 86 | 87 | # Check if the value might be a json string 88 | if not isinstance(value, str): 89 | return value 90 | 91 | # Make sure it starts with a brace 92 | if not value.startswith('{') or value.startswith('['): 93 | return value 94 | 95 | # Try to load the string 96 | try: 97 | return json.loads(value) 98 | except: 99 | return value 100 | 101 | def serialize_list(self, value): 102 | """ 103 | Ensure that all values of a list or tuple are serialized 104 | :return: 105 | """ 106 | 107 | # Check if this is a list or a tuple 108 | if not isinstance(value, (list, tuple)): 109 | return value 110 | 111 | # Loop over all the values and serialize the values 112 | return [ 113 | self.serialize_value(list_value) 114 | for list_value in value 115 | ] 116 | 117 | def serialize_dict(self, value): 118 | """ 119 | Ensure that all values of a dictionary are properly serialized 120 | :param value: 121 | :return: 122 | """ 123 | 124 | # Check if this is a dict 125 | if not isinstance(value, dict): 126 | return value 127 | 128 | # Loop over all the values and serialize them 129 | return { 130 | dict_key: self.serialize_value(dict_value) 131 | for dict_key, dict_value in value.items() 132 | } 133 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " epub to make an epub" 33 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 34 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 35 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 36 | @echo " text to make text files" 37 | @echo " man to make manual pages" 38 | @echo " texinfo to make Texinfo files" 39 | @echo " info to make Texinfo files and run them through makeinfo" 40 | @echo " gettext to make PO message catalogs" 41 | @echo " changes to make an overview of all changed/added/deprecated items" 42 | @echo " xml to make Docutils-native XML files" 43 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 44 | @echo " linkcheck to check all external links for integrity" 45 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 46 | 47 | clean: 48 | rm -rf $(BUILDDIR)/* 49 | 50 | html: 51 | $(SPHINXBUILD) -W -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 52 | @echo 53 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 54 | 55 | dirhtml: 56 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 57 | @echo 58 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 59 | 60 | singlehtml: 61 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 62 | @echo 63 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 64 | 65 | pickle: 66 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 67 | @echo 68 | @echo "Build finished; now you can process the pickle files." 69 | 70 | json: 71 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 72 | @echo 73 | @echo "Build finished; now you can process the JSON files." 74 | 75 | htmlhelp: 76 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 77 | @echo 78 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 79 | ".hhp project file in $(BUILDDIR)/htmlhelp." 80 | 81 | epub: 82 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 83 | @echo 84 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 85 | 86 | latex: 87 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 88 | @echo 89 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 90 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 91 | "(use \`make latexpdf' here to do that automatically)." 92 | 93 | latexpdf: 94 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 95 | @echo "Running LaTeX files through pdflatex..." 96 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 97 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 98 | 99 | latexpdfja: 100 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 101 | @echo "Running LaTeX files through platex and dvipdfmx..." 102 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 103 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 104 | 105 | text: 106 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 107 | @echo 108 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 109 | 110 | man: 111 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 112 | @echo 113 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 114 | 115 | texinfo: 116 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 117 | @echo 118 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 119 | @echo "Run \`make' in that directory to run these through makeinfo" \ 120 | "(use \`make info' here to do that automatically)." 121 | 122 | info: 123 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 124 | @echo "Running Texinfo files through makeinfo..." 125 | make -C $(BUILDDIR)/texinfo info 126 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 127 | 128 | gettext: 129 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 130 | @echo 131 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 132 | 133 | changes: 134 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 135 | @echo 136 | @echo "The overview file is in $(BUILDDIR)/changes." 137 | 138 | linkcheck: 139 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 140 | @echo 141 | @echo "Link check complete; look for any errors in the above output " \ 142 | "or in $(BUILDDIR)/linkcheck/output.txt." 143 | 144 | doctest: 145 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 146 | @echo "Testing of doctests in the sources finished, look at the " \ 147 | "results in $(BUILDDIR)/doctest/output.txt." 148 | 149 | xml: 150 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 151 | @echo 152 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 153 | 154 | pseudoxml: 155 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 156 | @echo 157 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 158 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-entity-event documentation build configuration file 4 | import inspect 5 | import os 6 | import re 7 | 8 | # -- Django configuration ------------------------------------------------- 9 | import sys 10 | sys.path.insert(0, os.path.abspath('..')) 11 | from settings import configure_settings 12 | configure_settings() 13 | 14 | 15 | PY2 = sys.version_info[0] == 2 16 | if PY2: 17 | from django.utils.encoding import force_unicode 18 | else: 19 | def force_unicode(str): 20 | return str 21 | 22 | from django.utils.html import strip_tags 23 | 24 | 25 | def get_version(): 26 | """ 27 | Extracts the version number from the version.py file. 28 | """ 29 | VERSION_FILE = '../entity_event/version.py' 30 | mo = re.search(r'^__version__ = [\'"]([^\'"]*)[\'"]', open(VERSION_FILE, 'rt').read(), re.M) 31 | if mo: 32 | return mo.group(1) 33 | else: 34 | raise RuntimeError('Unable to find version string in {0}.'.format(VERSION_FILE)) 35 | 36 | # If extensions (or modules to document with autodoc) are in another directory, 37 | # add these directories to sys.path here. If the directory is relative to the 38 | # documentation root, use os.path.abspath to make it absolute, like shown here. 39 | #sys.path.insert(0, os.path.abspath('.')) 40 | 41 | # -- General configuration ------------------------------------------------ 42 | 43 | extensions = [ 44 | 'sphinx.ext.autodoc', 45 | 'sphinx.ext.intersphinx', 46 | #'sphinx.ext.viewcode', 47 | ] 48 | 49 | # Add any paths that contain templates here, relative to this directory. 50 | templates_path = ['_templates'] 51 | 52 | # The suffix of source filenames. 53 | source_suffix = '.rst' 54 | 55 | # The master toctree document. 56 | master_doc = 'index' 57 | 58 | # General information about the project. 59 | project = u'entity_event' 60 | copyright = u'2014, Ambition Inc.' 61 | 62 | # The short X.Y version. 63 | version = get_version() 64 | # The full version, including alpha/beta/rc tags. 65 | release = version 66 | 67 | exclude_patterns = ['_build'] 68 | 69 | # The name of the Pygments (syntax highlighting) style to use. 70 | pygments_style = 'sphinx' 71 | 72 | intersphinx_mapping = { 73 | 'python': ('http://python.readthedocs.org/en/v2.7.2/', None), 74 | 'django': ('http://django.readthedocs.org/en/latest/', None), 75 | #'celery': ('http://celery.readthedocs.org/en/latest/', None), 76 | } 77 | 78 | # -- Options for HTML output ---------------------------------------------- 79 | 80 | html_theme = 'default' 81 | #html_theme_path = [] 82 | 83 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True' 84 | if not on_rtd: # only import and set the theme if we're building docs locally 85 | import sphinx_rtd_theme 86 | html_theme = 'sphinx_rtd_theme' 87 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 88 | 89 | # Add any paths that contain custom static files (such as style sheets) here, 90 | # relative to this directory. They are copied after the builtin static files, 91 | # so a file named "default.css" will overwrite the builtin "default.css". 92 | # html_static_path = ['_static'] 93 | 94 | # Custom sidebar templates, maps document names to template names. 95 | #html_sidebars = {} 96 | 97 | # Additional templates that should be rendered to pages, maps page names to 98 | # template names. 99 | #html_additional_pages = {} 100 | 101 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 102 | html_show_sphinx = False 103 | 104 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 105 | html_show_copyright = True 106 | 107 | # Output file base name for HTML help builder. 108 | htmlhelp_basename = 'django-entity-eventdoc' 109 | 110 | 111 | # -- Options for LaTeX output --------------------------------------------- 112 | 113 | latex_elements = { 114 | # The paper size ('letterpaper' or 'a4paper'). 115 | #'papersize': 'letterpaper', 116 | 117 | # The font size ('10pt', '11pt' or '12pt'). 118 | #'pointsize': '10pt', 119 | 120 | # Additional stuff for the LaTeX preamble. 121 | #'preamble': '', 122 | } 123 | 124 | # Grouping the document tree into LaTeX files. List of tuples 125 | # (source start file, target name, title, 126 | # author, documentclass [howto, manual, or own class]). 127 | latex_documents = [ 128 | ('index', 'django-entity-event.tex', u'django-entity-event Documentation', 129 | u'Erik Swanson', 'manual'), 130 | ] 131 | 132 | # -- Options for manual page output --------------------------------------- 133 | 134 | # One entry per manual page. List of tuples 135 | # (source start file, name, description, authors, manual section). 136 | man_pages = [ 137 | ('index', 'django-entity-event', u'django-entity-event Documentation', 138 | [u'Erik Swanson'], 1) 139 | ] 140 | 141 | # -- Options for Texinfo output ------------------------------------------- 142 | 143 | # Grouping the document tree into Texinfo files. List of tuples 144 | # (source start file, target name, title, author, 145 | # dir menu entry, description, category) 146 | texinfo_documents = [ 147 | ('index', 'django-entity-event', u'django-entity-event Documentation', 148 | u'Erik Swanson', 'django-entity-event', 'A short description', 149 | 'Miscellaneous'), 150 | ] 151 | 152 | 153 | # def process_django_model_docstring(app, what, name, obj, options, lines): 154 | # """ 155 | # Does special processing for django model docstrings, making docs for 156 | # fields in the model. 157 | # """ 158 | # # This causes import errors if left outside the function 159 | # from django.db import models 160 | 161 | # # Only look at objects that inherit from Django's base model class 162 | # if inspect.isclass(obj) and issubclass(obj, models.Model): 163 | # # Grab the field list from the meta class 164 | # fields = obj._meta.fields 165 | 166 | # for field in fields: 167 | # # Decode and strip any html out of the field's help text 168 | # help_text = strip_tags(force_unicode(field.help_text)) 169 | 170 | # # Decode and capitalize the verbose name, for use if there isn't 171 | # # any help text 172 | # verbose_name = force_unicode(field.verbose_name).capitalize() 173 | 174 | # if help_text: 175 | # # Add the model field to the end of the docstring as a param 176 | # # using the help text as the description 177 | # lines.append(u':param %s: %s' % (field.attname, help_text)) 178 | # else: 179 | # # Add the model field to the end of the docstring as a param 180 | # # using the verbose name as the description 181 | # lines.append(u':param %s: %s' % (field.attname, verbose_name)) 182 | 183 | # # Add the field's type to the docstring 184 | # lines.append(u':type %s: %s' % (field.attname, type(field).__name__)) 185 | 186 | # # Return the extended docstring 187 | # return lines 188 | 189 | 190 | # def setup(app): 191 | # # Register the docstring processor with sphinx 192 | # app.connect('autodoc-process-docstring', process_django_model_docstring) 193 | -------------------------------------------------------------------------------- /entity_event/tests/context_serializer_tests.py: -------------------------------------------------------------------------------- 1 | import json 2 | from django.test.testcases import TransactionTestCase 3 | from django_dynamic_fixture import G 4 | from unittest.mock import patch, call 5 | 6 | from entity_event.context_serializer import DefaultContextSerializer 7 | from entity_event.tests.models import TestModel 8 | 9 | 10 | class DefaultContextSerializerTests(TransactionTestCase): 11 | def setUp(self): 12 | super(DefaultContextSerializerTests, self).setUp() 13 | 14 | # Create some fake context to work with 15 | self.context = dict( 16 | test='test' 17 | ) 18 | 19 | # Create a serializer to test with 20 | self.serializer = DefaultContextSerializer(self.context) 21 | 22 | @patch.object(DefaultContextSerializer, 'serialize_value', autospec=True) 23 | def test_data_property(self, mock_serialize_value): 24 | # Call the property 25 | response = self.serializer.data 26 | 27 | # Assert that we have a proper response 28 | self.assertEqual(response, mock_serialize_value.return_value) 29 | 30 | @patch.object(DefaultContextSerializer, 'serialize_model', autospec=True) 31 | @patch.object(DefaultContextSerializer, 'serialize_json_string', autospec=True) 32 | @patch.object(DefaultContextSerializer, 'serialize_list', autospec=True) 33 | @patch.object(DefaultContextSerializer, 'serialize_dict', autospec=True) 34 | def test_serialize_value(self, *serialize_methods): 35 | # Setup the return values of each method 36 | for serialize_method in serialize_methods: 37 | serialize_method.return_value = self.context 38 | 39 | # Call the method 40 | response = self.serializer.serialize_value(self.context) 41 | 42 | # Assert we have a proper response 43 | self.assertEqual(response, serialize_methods[0].return_value) 44 | 45 | # Assert that each serialize method was called properly 46 | for serialize_method in serialize_methods: 47 | serialize_method.assert_called_once_with(self.serializer, self.context) 48 | 49 | def test_serialize_model_non_model(self): 50 | # Call the method 51 | response = self.serializer.serialize_model('test') 52 | 53 | # Assert we have a proper response 54 | self.assertEqual(response, 'test') 55 | 56 | def test_serialize_model(self): 57 | # Create a model to test with 58 | model = G(TestModel) 59 | 60 | # Fetch the model so we dont have the fks loaded and only select one related 61 | model = TestModel.objects.select_related('fk').get(id=model.id) 62 | 63 | # Call the method 64 | response = self.serializer.serialize_model(model) 65 | 66 | # Evaluate the fk_m2m because later djangos return a queryset 67 | response['fk_m2m'] = list(response['fk_m2m']) 68 | 69 | # Assert that we have a proper response 70 | self.assertEqual( 71 | response, 72 | { 73 | 'fk_m2m': [], 74 | 'fk2': model.fk2.id, 75 | 'fk': { 76 | 'id': model.fk.id, 77 | 'value': model.fk.value 78 | }, 79 | 'id': model.id, 80 | 'value': model.value 81 | } 82 | ) 83 | 84 | def test_serialize_json_string_non_string(self): 85 | # Call the method 86 | response = self.serializer.serialize_json_string(dict()) 87 | 88 | # Assert we have a proper response 89 | self.assertEqual(response, dict()) 90 | 91 | def test_serialize_json_string_non_json_string(self): 92 | # Call the method 93 | response = self.serializer.serialize_json_string('test') 94 | 95 | # Assert we have a proper response 96 | self.assertEqual(response, 'test') 97 | 98 | def test_serialize_json_string_bad_json_string(self): 99 | # Call the method 100 | response = self.serializer.serialize_json_string('{test') 101 | 102 | # Assert we have a proper response 103 | self.assertEqual(response, '{test') 104 | 105 | def test_serialize_json_string(self): 106 | # Create a json string to test 107 | test_dict = dict(test='test') 108 | test_json = json.dumps(test_dict) 109 | 110 | # Call the method 111 | response = self.serializer.serialize_json_string(test_json) 112 | 113 | # Assert that we have a proper response 114 | self.assertEqual( 115 | response, 116 | test_dict 117 | ) 118 | 119 | def test_serialize_list_non_list(self): 120 | # Call the method 121 | response = self.serializer.serialize_list('test') 122 | 123 | # Assert we have a proper response 124 | self.assertEqual(response, 'test') 125 | 126 | @patch.object(DefaultContextSerializer, 'serialize_value', autospec=True) 127 | def test_serialize_list_list(self, mock_serialize_value): 128 | # Setup a test list 129 | test_list = ['one', 'two', 'three'] 130 | 131 | # Call the method 132 | response = self.serializer.serialize_list(test_list) 133 | 134 | # Assert that we have the proper response 135 | self.assertEqual( 136 | response, 137 | [ 138 | mock_serialize_value.return_value, 139 | mock_serialize_value.return_value, 140 | mock_serialize_value.return_value, 141 | ] 142 | ) 143 | 144 | # Assert that we called serialize value on on values of the list 145 | self.assertEqual( 146 | mock_serialize_value.mock_calls, 147 | [ 148 | call(self.serializer, 'one'), 149 | call(self.serializer, 'two'), 150 | call(self.serializer, 'three'), 151 | ] 152 | ) 153 | 154 | @patch.object(DefaultContextSerializer, 'serialize_value', autospec=True) 155 | def test_serialize_list_tuple(self, mock_serialize_value): 156 | # Setup a test tuple 157 | test_tuple = ('one', 'two', 'three') 158 | 159 | # Call the method 160 | response = self.serializer.serialize_list(test_tuple) 161 | 162 | # Assert that we have the proper response 163 | self.assertEqual( 164 | response, 165 | [ 166 | mock_serialize_value.return_value, 167 | mock_serialize_value.return_value, 168 | mock_serialize_value.return_value, 169 | ] 170 | ) 171 | 172 | # Assert that we called serialize value on on values of the list 173 | self.assertEqual( 174 | mock_serialize_value.mock_calls, 175 | [ 176 | call(self.serializer, 'one'), 177 | call(self.serializer, 'two'), 178 | call(self.serializer, 'three'), 179 | ] 180 | ) 181 | 182 | def test_serialize_dict_non_dict(self): 183 | # Call the method 184 | response = self.serializer.serialize_dict('test') 185 | 186 | # Assert we have a proper response 187 | self.assertEqual(response, 'test') 188 | 189 | @patch.object(DefaultContextSerializer, 'serialize_value', autospec=True) 190 | def test_serialize_dict(self, mock_serialize_value): 191 | # Setup a test dict 192 | test_dict = dict(one='one', two='two') 193 | 194 | # Call the method 195 | response = self.serializer.serialize_dict(test_dict) 196 | 197 | # Assert we have a proper response 198 | self.assertEqual( 199 | response, 200 | dict( 201 | one=mock_serialize_value.return_value, 202 | two=mock_serialize_value.return_value, 203 | ) 204 | ) 205 | 206 | # Assert that we called serialize value on on values of the dict 207 | mock_serialize_value.assert_has_calls([ 208 | call(self.serializer, 'one'), 209 | call(self.serializer, 'two'), 210 | ], any_order=True) 211 | -------------------------------------------------------------------------------- /entity_event/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.db import models, migrations 4 | import django.db.models.deletion 5 | import datetime 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('entity', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='ContextRenderer', 17 | fields=[ 18 | ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')), 19 | ('name', models.CharField(max_length=64, unique=True)), 20 | ('text_template_path', models.CharField(max_length=256, default='')), 21 | ('html_template_path', models.CharField(max_length=256, default='')), 22 | ('text_template', models.TextField(default='')), 23 | ('html_template', models.TextField(default='')), 24 | ('context_hints', models.JSONField(null=True, default=None)), 25 | ], 26 | options={ 27 | }, 28 | bases=(models.Model,), 29 | ), 30 | migrations.CreateModel( 31 | name='Event', 32 | fields=[ 33 | ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')), 34 | ('context', models.JSONField()), 35 | ('time', models.DateTimeField(auto_now_add=True, db_index=True)), 36 | ('time_expires', models.DateTimeField(db_index=True, default=datetime.datetime(9999, 12, 31, 23, 59, 59, 999999))), 37 | ('uuid', models.CharField(max_length=128, unique=True)), 38 | ], 39 | options={ 40 | }, 41 | bases=(models.Model,), 42 | ), 43 | migrations.CreateModel( 44 | name='EventActor', 45 | fields=[ 46 | ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')), 47 | ('entity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity.Entity')), 48 | ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity_event.Event')), 49 | ], 50 | options={ 51 | }, 52 | bases=(models.Model,), 53 | ), 54 | migrations.CreateModel( 55 | name='EventSeen', 56 | fields=[ 57 | ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')), 58 | ('time_seen', models.DateTimeField(default=datetime.datetime.utcnow)), 59 | ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity_event.Event')), 60 | ], 61 | options={ 62 | }, 63 | bases=(models.Model,), 64 | ), 65 | migrations.CreateModel( 66 | name='Medium', 67 | fields=[ 68 | ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')), 69 | ('name', models.CharField(max_length=64, unique=True)), 70 | ('display_name', models.CharField(max_length=64)), 71 | ('description', models.TextField()), 72 | ('additional_context', models.JSONField(null=True, default=None)), 73 | ], 74 | options={ 75 | }, 76 | bases=(models.Model,), 77 | ), 78 | migrations.CreateModel( 79 | name='RenderingStyle', 80 | fields=[ 81 | ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')), 82 | ('name', models.CharField(max_length=64, unique=True)), 83 | ('display_name', models.CharField(max_length=64, default='')), 84 | ], 85 | options={ 86 | }, 87 | bases=(models.Model,), 88 | ), 89 | migrations.CreateModel( 90 | name='Source', 91 | fields=[ 92 | ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')), 93 | ('name', models.CharField(max_length=64, unique=True)), 94 | ('display_name', models.CharField(max_length=64)), 95 | ('description', models.TextField()), 96 | ], 97 | options={ 98 | }, 99 | bases=(models.Model,), 100 | ), 101 | migrations.CreateModel( 102 | name='SourceGroup', 103 | fields=[ 104 | ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')), 105 | ('name', models.CharField(max_length=64, unique=True)), 106 | ('display_name', models.CharField(max_length=64)), 107 | ('description', models.TextField()), 108 | ], 109 | options={ 110 | }, 111 | bases=(models.Model,), 112 | ), 113 | migrations.CreateModel( 114 | name='Subscription', 115 | fields=[ 116 | ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')), 117 | ('only_following', models.BooleanField(default=True)), 118 | ('entity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='entity.Entity')), 119 | ('medium', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity_event.Medium')), 120 | ('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity_event.Source')), 121 | ('sub_entity_kind', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, null=True, related_name='+', to='entity.EntityKind', default=None)), 122 | ], 123 | options={ 124 | }, 125 | bases=(models.Model,), 126 | ), 127 | migrations.CreateModel( 128 | name='Unsubscription', 129 | fields=[ 130 | ('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')), 131 | ('entity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity.Entity')), 132 | ('medium', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity_event.Medium')), 133 | ('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity_event.Source')), 134 | ], 135 | options={ 136 | }, 137 | bases=(models.Model,), 138 | ), 139 | migrations.AddField( 140 | model_name='source', 141 | name='group', 142 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity_event.SourceGroup'), 143 | preserve_default=True, 144 | ), 145 | migrations.AddField( 146 | model_name='medium', 147 | name='rendering_style', 148 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity_event.RenderingStyle', null=True), 149 | preserve_default=True, 150 | ), 151 | migrations.AddField( 152 | model_name='eventseen', 153 | name='medium', 154 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity_event.Medium'), 155 | preserve_default=True, 156 | ), 157 | migrations.AlterUniqueTogether( 158 | name='eventseen', 159 | unique_together=set([('event', 'medium')]), 160 | ), 161 | migrations.AddField( 162 | model_name='event', 163 | name='source', 164 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity_event.Source'), 165 | preserve_default=True, 166 | ), 167 | migrations.AddField( 168 | model_name='contextrenderer', 169 | name='rendering_style', 170 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity_event.RenderingStyle'), 171 | preserve_default=True, 172 | ), 173 | migrations.AddField( 174 | model_name='contextrenderer', 175 | name='source', 176 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity_event.Source', null=True), 177 | preserve_default=True, 178 | ), 179 | migrations.AddField( 180 | model_name='contextrenderer', 181 | name='source_group', 182 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity_event.SourceGroup', null=True), 183 | preserve_default=True, 184 | ), 185 | migrations.AlterUniqueTogether( 186 | name='contextrenderer', 187 | unique_together=set([('source', 'rendering_style')]), 188 | ), 189 | migrations.CreateModel( 190 | name='AdminEvent', 191 | fields=[ 192 | ], 193 | options={ 194 | 'proxy': True, 195 | }, 196 | bases=('entity_event.event',), 197 | ), 198 | ] 199 | -------------------------------------------------------------------------------- /entity_event/migrations/0001_0005_squashed.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.19 on 2023-05-31 20:54 2 | 3 | import datetime 4 | import django.core.serializers.json 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | replaces = [('entity_event', '0001_initial'), ('entity_event', '0002_medium_creation_time'), 11 | ('entity_event', '0003_auto_20170830_1321'), ('entity_event', '0004_auto_20180403_1655'), 12 | ('entity_event', '0005_auto_20200409_1612')] 13 | 14 | dependencies = [ 15 | ('entity', '0001_initial'), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='Event', 21 | fields=[ 22 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 23 | ('context', models.JSONField()), 24 | ('time', models.DateTimeField(auto_now_add=True, db_index=True)), 25 | ('time_expires', models.DateTimeField(db_index=True, default=datetime.datetime(9999, 12, 31, 23, 59, 59, 999999))), 26 | ('uuid', models.CharField(max_length=128, unique=True)), 27 | ], 28 | ), 29 | migrations.CreateModel( 30 | name='EventActor', 31 | fields=[ 32 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 33 | ('entity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity.entity')), 34 | ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity_event.event')), 35 | ], 36 | ), 37 | migrations.CreateModel( 38 | name='Medium', 39 | fields=[ 40 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 41 | ('name', models.CharField(max_length=64, unique=True)), 42 | ('display_name', models.CharField(max_length=64)), 43 | ('description', models.TextField()), 44 | ('additional_context', models.JSONField(default=None, null=True)), 45 | ], 46 | ), 47 | migrations.CreateModel( 48 | name='RenderingStyle', 49 | fields=[ 50 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 51 | ('name', models.CharField(max_length=256, unique=True)), 52 | ('display_name', models.CharField(default='', max_length=256)), 53 | ], 54 | ), 55 | migrations.CreateModel( 56 | name='Source', 57 | fields=[ 58 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 59 | ('name', models.CharField(max_length=64, unique=True)), 60 | ('display_name', models.CharField(max_length=64)), 61 | ('description', models.TextField()), 62 | ], 63 | ), 64 | migrations.CreateModel( 65 | name='SourceGroup', 66 | fields=[ 67 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 68 | ('name', models.CharField(max_length=256, unique=True)), 69 | ('display_name', models.CharField(max_length=256)), 70 | ('description', models.TextField()), 71 | ], 72 | ), 73 | migrations.CreateModel( 74 | name='Subscription', 75 | fields=[ 76 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 77 | ('only_following', models.BooleanField(default=True)), 78 | ('entity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='entity.entity')), 79 | ('medium', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity_event.medium')), 80 | ('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity_event.source')), 81 | ('sub_entity_kind', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='entity.entitykind')), 82 | ], 83 | ), 84 | migrations.CreateModel( 85 | name='Unsubscription', 86 | fields=[ 87 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 88 | ('entity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity.entity')), 89 | ('medium', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity_event.medium')), 90 | ('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity_event.source')), 91 | ], 92 | ), 93 | migrations.AddField( 94 | model_name='source', 95 | name='group', 96 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity_event.sourcegroup'), 97 | ), 98 | migrations.AddField( 99 | model_name='medium', 100 | name='rendering_style', 101 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='entity_event.renderingstyle'), 102 | ), 103 | migrations.CreateModel( 104 | name='EventSeen', 105 | fields=[ 106 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 107 | ('time_seen', models.DateTimeField(default=datetime.datetime.utcnow)), 108 | ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity_event.event')), 109 | ('medium', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity_event.medium')), 110 | ], 111 | options={ 112 | 'unique_together': {('event', 'medium')}, 113 | }, 114 | ), 115 | migrations.AddField( 116 | model_name='event', 117 | name='source', 118 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity_event.source'), 119 | ), 120 | migrations.CreateModel( 121 | name='AdminEvent', 122 | fields=[ 123 | ], 124 | options={ 125 | 'proxy': True, 126 | }, 127 | bases=('entity_event.event',), 128 | ), 129 | migrations.AddField( 130 | model_name='medium', 131 | name='time_created', 132 | field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2017, 5, 24, 18, 7, 45, 987701)), 133 | preserve_default=False, 134 | ), 135 | migrations.AlterField( 136 | model_name='event', 137 | name='uuid', 138 | field=models.CharField(max_length=512, unique=True), 139 | ), 140 | migrations.AlterField( 141 | model_name='event', 142 | name='context', 143 | field=models.JSONField(encoder=django.core.serializers.json.DjangoJSONEncoder), 144 | ), 145 | migrations.AlterField( 146 | model_name='medium', 147 | name='additional_context', 148 | field=models.JSONField(default=None, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True), 149 | ), 150 | migrations.CreateModel( 151 | name='ContextRenderer', 152 | fields=[ 153 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 154 | ('name', models.CharField(max_length=256, unique=True)), 155 | ('text_template_path', models.CharField(default='', max_length=256)), 156 | ('html_template_path', models.CharField(default='', max_length=256)), 157 | ('text_template', models.TextField(default='')), 158 | ('html_template', models.TextField(default='')), 159 | ('context_hints', models.JSONField(default=None, encoder=django.core.serializers.json.DjangoJSONEncoder, null=True)), 160 | ('rendering_style', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='entity_event.renderingstyle')), 161 | ('source', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='entity_event.source')), 162 | ('source_group', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='entity_event.sourcegroup')), 163 | ], 164 | options={ 165 | 'unique_together': {('source', 'rendering_style')}, 166 | }, 167 | ), 168 | migrations.AlterField( 169 | model_name='medium', 170 | name='display_name', 171 | field=models.CharField(max_length=256), 172 | ), 173 | migrations.AlterField( 174 | model_name='medium', 175 | name='name', 176 | field=models.CharField(max_length=256, unique=True), 177 | ), 178 | migrations.AlterField( 179 | model_name='source', 180 | name='display_name', 181 | field=models.CharField(max_length=256), 182 | ), 183 | migrations.AlterField( 184 | model_name='source', 185 | name='name', 186 | field=models.CharField(max_length=256, unique=True), 187 | ), 188 | ] 189 | -------------------------------------------------------------------------------- /entity_event/context_loader.py: -------------------------------------------------------------------------------- 1 | """ 2 | A module for loading contexts using context hints. 3 | """ 4 | from collections import defaultdict 5 | 6 | from django.conf import settings 7 | from django.db.models import Q 8 | from django.apps import apps 9 | get_model = apps.get_model 10 | from manager_utils import id_dict 11 | 12 | from entity_event.models import ContextRenderer 13 | 14 | 15 | def get_context_hints_per_source(context_renderers): 16 | """ 17 | Given a list of context renderers, return a dictionary of context hints per source. 18 | """ 19 | # Merge the context render hints for each source as there can be multiple context hints for 20 | # sources depending on the render target. Merging them together involves combining select 21 | # and prefetch related hints for each context renderer 22 | context_hints_per_source = defaultdict(lambda: defaultdict(lambda: { 23 | 'app_name': None, 24 | 'model_name': None, 25 | 'select_related': set(), 26 | 'prefetch_related': set(), 27 | })) 28 | for cr in context_renderers: 29 | for key, hints in cr.context_hints.items() if cr.context_hints else []: 30 | for source in cr.get_sources(): 31 | context_hints_per_source[source][key]['app_name'] = hints['app_name'] 32 | context_hints_per_source[source][key]['model_name'] = hints['model_name'] 33 | context_hints_per_source[source][key]['select_related'].update(hints.get('select_related', [])) 34 | context_hints_per_source[source][key]['prefetch_related'].update(hints.get('prefetch_related', [])) 35 | 36 | return context_hints_per_source 37 | 38 | 39 | def get_querysets_for_context_hints(context_hints_per_source): 40 | """ 41 | Given a list of context hint dictionaries, return a dictionary 42 | of querysets for efficient context loading. The return value 43 | is structured as follows: 44 | 45 | { 46 | model: queryset, 47 | ... 48 | } 49 | """ 50 | model_select_relateds = defaultdict(set) 51 | model_prefetch_relateds = defaultdict(set) 52 | model_querysets = {} 53 | for context_hints in context_hints_per_source.values(): 54 | for hints in context_hints.values(): 55 | model = get_model(hints['app_name'], hints['model_name']) 56 | model_querysets[model] = model.objects 57 | model_select_relateds[model].update(hints.get('select_related', [])) 58 | model_prefetch_relateds[model].update(hints.get('prefetch_related', [])) 59 | 60 | # Attach select and prefetch related parameters to the querysets if needed 61 | for model, queryset in model_querysets.items(): 62 | if model_select_relateds[model]: 63 | queryset = queryset.select_related(*model_select_relateds[model]) 64 | if model_prefetch_relateds[model]: 65 | queryset = queryset.prefetch_related(*model_prefetch_relateds[model]) 66 | model_querysets[model] = queryset 67 | 68 | return model_querysets 69 | 70 | 71 | def dict_find(d, which_key): 72 | """ 73 | Finds key values in a nested dictionary. Returns a tuple of the dictionary in which 74 | the key was found along with the value 75 | """ 76 | # If the starting point is a list, iterate recursively over all values 77 | if isinstance(d, (list, tuple)): 78 | for i in d: 79 | for result in dict_find(i, which_key): 80 | yield result 81 | 82 | # Else, iterate over all key values of the dictionary 83 | elif isinstance(d, dict): 84 | for k, v in d.items(): 85 | if k == which_key: 86 | yield d, v 87 | for result in dict_find(v, which_key): 88 | yield result 89 | 90 | 91 | def get_model_ids_to_fetch(events, context_hints_per_source): 92 | """ 93 | Obtains the ids of all models that need to be fetched. Returns a dictionary of models that 94 | point to sets of ids that need to be fetched. Return output is as follows: 95 | 96 | { 97 | model: [id1, id2, ...], 98 | ... 99 | } 100 | """ 101 | number_types = (complex, float, int) 102 | model_ids_to_fetch = defaultdict(set) 103 | 104 | for event in events: 105 | context_hints = context_hints_per_source.get(event.source, {}) 106 | for context_key, hints in context_hints.items(): 107 | for d, value in dict_find(event.context, context_key): 108 | values = value if isinstance(value, list) else [value] 109 | model_ids_to_fetch[get_model(hints['app_name'], hints['model_name'])].update( 110 | v for v in values if isinstance(v, number_types) 111 | ) 112 | 113 | return model_ids_to_fetch 114 | 115 | 116 | def fetch_model_data(model_querysets, model_ids_to_fetch): 117 | """ 118 | Given a dictionary of models to querysets and model IDs to models, fetch the IDs 119 | for every model and return the objects in the following structure. 120 | 121 | { 122 | model: { 123 | id: obj, 124 | ... 125 | }, 126 | ... 127 | } 128 | """ 129 | return { 130 | model: id_dict(model_querysets[model].filter(id__in=ids_to_fetch)) 131 | for model, ids_to_fetch in model_ids_to_fetch.items() 132 | } 133 | 134 | 135 | def load_fetched_objects_into_contexts(events, model_data, context_hints_per_source): 136 | """ 137 | Given the fetched model data and the context hints for each source, go through each 138 | event and populate the contexts with the loaded information. 139 | """ 140 | for event in events: 141 | context_hints = context_hints_per_source.get(event.source, {}) 142 | for context_key, hints in context_hints.items(): 143 | model = get_model(hints['app_name'], hints['model_name']) 144 | for d, value in dict_find(event.context, context_key): 145 | if isinstance(value, list): 146 | for i, model_id in enumerate(d[context_key]): 147 | d[context_key][i] = model_data[model].get(model_id) 148 | else: 149 | d[context_key] = model_data[model].get(value) 150 | 151 | 152 | def load_renderers_into_events(events, mediums, context_renderers, default_rendering_style): 153 | """ 154 | Given the events and the context renderers, load the renderers into the event objects 155 | so that they may be able to call the 'render' method later on. 156 | """ 157 | # Make a mapping of source groups and rendering styles to context renderers. Do 158 | # the same for sources and rendering styles to context renderers 159 | source_group_style_to_renderer = { 160 | (cr.source_group_id, cr.rendering_style_id): cr 161 | for cr in context_renderers if cr.source_group_id 162 | } 163 | source_style_to_renderer = { 164 | (cr.source_id, cr.rendering_style_id): cr 165 | for cr in context_renderers if cr.source_id 166 | } 167 | 168 | for e in events: 169 | for m in mediums: 170 | # Try the following when loading a context renderer for a medium in an event. 171 | # 1. Try to look up the renderer based on the source group and medium rendering style 172 | # 2. If step 1 doesn't work, look up based on the source and medium rendering style 173 | # 3. If step 2 doesn't work, look up based on the source group and default rendering style 174 | # 4. if step 3 doesn't work, look up based on the source and default rendering style 175 | # If none of those steps work, this event will not be able to be rendered for the mediun 176 | cr = source_group_style_to_renderer.get((e.source.group_id, m.rendering_style_id)) 177 | if not cr: 178 | cr = source_style_to_renderer.get((e.source_id, m.rendering_style_id)) 179 | if not cr and default_rendering_style: 180 | cr = source_group_style_to_renderer.get((e.source.group_id, default_rendering_style.id)) 181 | if not cr and default_rendering_style: 182 | cr = source_style_to_renderer.get((e.source_id, default_rendering_style.id)) 183 | 184 | if cr: 185 | e._context_renderers[m] = cr 186 | 187 | 188 | def get_default_rendering_style(): 189 | default_rendering_style = getattr(settings, 'DEFAULT_ENTITY_EVENT_RENDERING_STYLE', None) 190 | if default_rendering_style: 191 | default_rendering_style = get_model('entity_event', 'RenderingStyle').objects.get(name=default_rendering_style) 192 | 193 | return default_rendering_style 194 | 195 | 196 | def load_contexts_and_renderers(events, mediums): 197 | """ 198 | Given a list of events and mediums, load the context model data into the contexts of the events. 199 | """ 200 | sources = {event.source for event in events} 201 | rendering_styles = {medium.rendering_style for medium in mediums if medium.rendering_style} 202 | 203 | # Fetch the default rendering style and add it to the set of rendering styles 204 | default_rendering_style = get_default_rendering_style() 205 | if default_rendering_style: 206 | rendering_styles.add(default_rendering_style) 207 | 208 | context_renderers = ContextRenderer.objects.filter( 209 | Q(source__in=sources, rendering_style__in=rendering_styles) | 210 | Q(source_group_id__in=[s.group_id for s in sources], rendering_style__in=rendering_styles)).select_related( 211 | 'source', 'rendering_style').prefetch_related('source_group__source_set') 212 | 213 | context_hints_per_source = get_context_hints_per_source(context_renderers) 214 | model_querysets = get_querysets_for_context_hints(context_hints_per_source) 215 | model_ids_to_fetch = get_model_ids_to_fetch(events, context_hints_per_source) 216 | model_data = fetch_model_data(model_querysets, model_ids_to_fetch) 217 | load_fetched_objects_into_contexts(events, model_data, context_hints_per_source) 218 | load_renderers_into_events(events, mediums, context_renderers, default_rendering_style) 219 | 220 | return events 221 | -------------------------------------------------------------------------------- /docs/advanced_features.rst: -------------------------------------------------------------------------------- 1 | Advanced Features 2 | ================= 3 | 4 | The :ref:`quickstart` guide covers the common use cases of Django Entity 5 | Event. In addition to the basic uses for creating, storing, and 6 | querying events, there are some more advanced uses supported for 7 | making Django Entity Event more efficient and flexible. 8 | 9 | This guide will cover the following advanced use cases: 10 | 11 | - Dynamically loading context using ``context_loader`` 12 | - Customizing the behavior of ``only_following`` by sub-classing 13 | :py:class:`~entity_event.models.Medium`. 14 | 15 | 16 | Rendering Events 17 | ---------------- 18 | 19 | Django Entity Event comes complete with a rendering system for events. This is accomplished 20 | by the setup of two different models: 21 | 22 | 1. :py:class:`~entity_event.models.RenderingStyle`: Defines a style of rendering. 23 | 2. :py:class:`~entity_event.models.ContextRenderer`: Defines the templates used 24 | for rendering, which rendering style it is, which source or source group it renders, 25 | and hints for fetching model PKs that are in event contexts. 26 | 27 | When these models are in place, :py:class:`~entity_event.models.Medium` models can be configured 28 | to point to a ``rendering_style`` of their choice. Events that have sources or source groups that 29 | match those configured in associated :py:class:`~entity_event.models.ContextRenderer` models 30 | can then be rendered using the ``render`` method on the medium. 31 | 32 | The configuration and rendering is best explained using a complete example. First, let's 33 | imagine that we are storing events that have contexts with information about Django User models. 34 | These events have a source called ``user_logged_in`` and track every time a user logs in. An 35 | example context is as follows: 36 | 37 | .. code-block:: python 38 | 39 | { 40 | 'user': 1, # The PK of the Django User model 41 | 'login_time': 'Jan 10, 2014', # The time the user logged in 42 | } 43 | 44 | Now let's say we have a Django template, ``user_logged_in.html`` that looks like the following: 45 | 46 | .. code-block:: python 47 | 48 | User {{ user.username }} logged in at {{ login_time }} 49 | 50 | In order to render the event with this template, we first set up a rendering style. This rendering 51 | style is pretty short and could probably be displayed in many places that want to display short 52 | messages (like a notification bar). So, we can make a ``short`` rendering style as followings: 53 | 54 | .. code-block:: python 55 | 56 | short_rendering_style = RenderingStyle.objects.create( 57 | name='short', 58 | display_name='Short Rendering Style') 59 | 60 | Now that we have our rendering style, we need to create a context renderer that has information about 61 | what templates, source, rendering style, and context hints to use when rendering the event. In our case, 62 | it would look like the following: 63 | 64 | .. code-block:: python 65 | 66 | context_renderer = ContextRenderer.objects.create( 67 | render_style=RenderingStyle.objects.get(name='short'), 68 | name='short_login_renderer', 69 | html_template_path='my_template_dir/user_logged_in.html', 70 | source=Source.objects.get(name='user_logged_in'), 71 | context_hints={ 72 | 'user': { 73 | 'app_name': 'auth', 74 | 'model_name': 'User', 75 | } 76 | } 77 | ) 78 | 79 | In the above, we set up the context renderer to use the short rendering style, pointed it to our html template 80 | that we created, and also pointed it to the source of the event. As you can see from the html template, we 81 | want to reach inside of the Django User object and display the ``username`` field. In order to retrieve this 82 | information, we have told our context renderer to treat the ``user`` key from the event context as a PK 83 | to a Django ``User`` model that resides in the ``auth`` app. 84 | 85 | With this information, we can now render the event using whatever medium we have set up in Django Entity 86 | Event. 87 | 88 | .. code-block:: python 89 | 90 | notification_medium = Medium.objects.get(name='notification') 91 | events = notification_medium.events() 92 | 93 | # Assume that two events were returned that have the following contexts 94 | # e1.context = { 95 | # 'user': 1, # Points to Jeff's user object 96 | # 'login_time': 'January 1, 2015', 97 | # } 98 | # e1.context = { 99 | # 'user': 2, # Points to Wes's user object 100 | # 'login_time': 'February 28, 2015', 101 | # } 102 | # 103 | # Pass the events into the medium's render method 104 | rendered_events = notification_medium.render(events) 105 | 106 | # The results are a dictionary keyed on each event. The keys point to a tuple 107 | # of text and html renderings. 108 | print(rendered_events[0][1]) 109 | 'jeff logged in at January 1, 2015' 110 | print(rendered_events[1][1]) 111 | 'wes logged in at February 28, 2015' 112 | 113 | With the notion of rendering styles, the notification medium and any medium that can display short 114 | messages can utilize the renderings of the events. Other rendering styles can still be made for 115 | more complex renderings such as emails with special styling. For more advanced options on how 116 | to perform prefetch and select_relateds in the fetched contexts, 117 | view :py:class:`~entity_event.models.ContextRenderer`. 118 | 119 | Advanced Template Rendering Options 120 | ----------------------------------- 121 | 122 | Along with the basic rendering capabilities, Django Entity Event comes with several other options 123 | and configurations for making rendering more robust. 124 | 125 | Doing Prefetch and Select Related on Contexts 126 | +++++++++++++++++++++++++++++++++++++++++++++ 127 | 128 | If you need to fetch additional relationships related to the model objects in the context data, a 129 | ``select_related`` key with a list of arguments can be provided to the dictionary of the model 130 | object you are fetching. The same is true for 131 | ``prefetch_related`` arguments as well. For example: 132 | 133 | .. code-block:: python 134 | 135 | context_hints = { 136 | 'account': { 137 | 'app_name': 'my_account_app', 138 | 'model_name': 'Account', 139 | 'select_related': ['user'], # Select the user object in the account model 140 | 'prefetch_related': ['user__groups'], # Prefetch user groups related to the account model 141 | } 142 | } 143 | 144 | Note that other context loaders can provide additional arguments to ``select_related`` and ``prefetch_related``. 145 | Additional arguments provided by other context loaders will simply be unioned together when loading 146 | contexts of all events at once. 147 | 148 | Passing Additional Context to Templates 149 | +++++++++++++++++++++++++++++++++++++++ 150 | 151 | Sometimes mediums need to have subtle differences in the rendering of their contexts. For example, headers 152 | might need to be added above and below a message or images might need to be displayed. For cases such as 153 | this, mediums come with an ``additional_context`` variable. Anything in this variable will always be 154 | passed into the context when events are rendered for that particular medium. 155 | 156 | Using a Default Rendering Style 157 | +++++++++++++++++++++++++++++++ 158 | 159 | It can be cumbersome to set up context renderers for every particular rendering style when it isn't 160 | necessary. For example, sometimes tailored emails need a special rendering style, however, many events 161 | can be rendered in an email just fine with a simpler rendering style. For these cases, a user can set 162 | a Django setting called ``DEFAULT_ENTITY_EVENT_RENDERING_STYLE`` that points to the name of the 163 | default rendering style to use. If this variable is set and an appropriate context loader cannot 164 | be fetched for an event during rendering, the default rendering style will be used instead for 165 | that event (if it has been configured). 166 | 167 | Serialized Context Data 168 | +++++++++++++++++++++++ 169 | 170 | If your display mechanism needs access to the context data of the event this can be accomplished by calling: 171 | :py:meth:`Event.get_serialized_context 172 | ` method on the 173 | :py:class:`~entity_event.models.Event` model. This will return a serializer safe version of the context that is used 174 | to generate the event output. This is useful if you want to make a completely custom rendering on the display device 175 | or you need additional context information about the event that occurred. 176 | 177 | 178 | Customizing Only-Following Behavior 179 | ----------------------------------- 180 | 181 | In the quickstart, we discussed the use of "only following" 182 | subscriptions to ensure that users only see the events that they are 183 | interested in. In this discussion, we mentioned that by default, 184 | entities follow themselves, and their super entities. This following 185 | relationship is defined in two methods on the 186 | :py:class:`~entity_event.models.Medium` model: 187 | :py:meth:`Medium.followers_of 188 | ` and 189 | :py:meth:`Medium.followed_by 190 | `. These two methods are 191 | inverses of each other and are used by the code that fetches events to 192 | determine the semantics of "only following" subscriptions. 193 | 194 | It is possible to customize the behavior of these types of 195 | subscriptions by concretely inheriting from 196 | :py:class:`~entity_event.models.Medium`, and overriding these two 197 | functions. For example, we could define a type of medium that provides 198 | the opposite behavior, where entities follow themselves and their 199 | sub-entities. 200 | 201 | .. code-block:: python 202 | 203 | from entity import Entity, EntityRelationship 204 | from entity_event import Medium 205 | 206 | class FollowSubEntitiesMedium(Medium): 207 | def followers_of(self, entities): 208 | if isinstance(entities, Entity): 209 | entities = Entity.objects.filter(id=entities.id) 210 | super_entities = EntityRelationship.objects.filter( 211 | sub_entity__in=entities).values_list('super_entity') 212 | followed_by = Entity.objects.filter( 213 | Q(id__in=entities) | Q(id__in=super_entities)) 214 | return followed_by 215 | 216 | def followed_by(self, entities): 217 | if isinstance(entities, Entity): 218 | entities = Entity.objects.filter(id=entities.id) 219 | sub_entities = EntityRelationship.objects.filter( 220 | super_entity__in=entities).values_list('sub_entity') 221 | followers_of = Entity.objects.filter( 222 | Q(id__in=entities) | Q(id__in=sub_entities)) 223 | return followers_of 224 | 225 | With these methods overridden, the behavior of the methods 226 | ``FollowsubEntitiesMedium.events``, 227 | ``FollowsubEntitiesMedium.entity_events``, and 228 | ``FollowsubEntitiesMedium.events_targets`` should all behave as 229 | expected. 230 | 231 | It is entirely possible to define more complex following 232 | relationships, potentially drawing on different source of information 233 | for what entities should follow what entities. The only important 234 | consideration is that the ``followers_of`` method must be the inverse 235 | of the ``followed_by`` method. That is, for any set of entities, it 236 | must hold that 237 | 238 | .. code-block:: python 239 | 240 | followers_of(followed_by(entities)) == entities 241 | 242 | and 243 | 244 | .. code-block:: python 245 | 246 | followed_by(followers_of(entities)) == entities 247 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | .. _quickstart: 2 | 3 | Quickstart and Basic Usage 4 | ========================== 5 | 6 | Django Entity Event is a great way to collect events that your users 7 | care about into a unified location. The parts of your code base that 8 | create these events are probably totally separate from the parts that 9 | display them, which are also separate from the parts that manage 10 | subscriptions to notifications. Django Entity Event makes separating 11 | these concerns as simple as possible, and provides convenient 12 | abstractions at each of these levels. 13 | 14 | This quickstart guide handles the three parts of managing events and 15 | notifications. 16 | 17 | 1. Creating, and categorizing events. 18 | 2. Defining mediums and subscriptions. 19 | 3. Querying events and presenting them to users. 20 | 21 | If you are not already using Django Entity, this event framework won't 22 | be particularly useful, and you should probably start by integrating 23 | Django Entity into your application. 24 | 25 | 26 | Creating and Categorizing Events 27 | -------------------------------- 28 | 29 | Django Entity Event is structured such that all events come from a 30 | :py:class:`~entity_event.models.Source`, and can be displayed to the 31 | user from a variety of mediums. When we're creating events, we 32 | don't need to worry much about what 33 | :py:class:`~entity_event.models.Medium` the event will be displayed 34 | on, we do need to know what the 35 | :py:class:`~entity_event.models.Source` of the events are. 36 | 37 | :py:class:`~entity_event.models.Source` objects are used to categorize 38 | events. Categorizing events allows different types of events to be 39 | consumed differently. So, before we can create an event, we need to 40 | create a :py:class:`~entity_event.models.Source` object. It is a good 41 | idea to use sources to do fine grained categorization of events. To 42 | provide higher level groupings, all sources must reference a 43 | :py:class:`~entity_event.models.SourceGroup` object. These objects are 44 | very simple to create. Here we will make a single source group and two 45 | different sources 46 | 47 | .. code-block:: python 48 | 49 | from entity_event import Source, SourceGroup 50 | 51 | yoursite_group = SourceGroup.objects.create( 52 | name='yoursite', 53 | display_name='Yoursite', 54 | description='Events on Yoursite' 55 | ) 56 | 57 | photo_source = Source.objects.create( 58 | group=yoursite_group, 59 | name='photo-tag', 60 | display_name='Photo Tag', 61 | description='You have been tagged in a photo' 62 | ) 63 | 64 | product_source = Source.objects.create( 65 | group=yoursite_group, 66 | name='new-product', 67 | display_name='New Product', 68 | description='There is a new product on YourSite' 69 | ) 70 | 71 | As seen above, the information required for these sources is fairly 72 | minimal. It is worth noting that while we only defined a single 73 | :py:class:`~entity_event.models.SourceGroup` object, it will often 74 | make sense to define more logical 75 | :py:class:`~entity_event.models.SourceGroup` objects. 76 | 77 | Once we have sources defined, we can begin creating events. To create 78 | an event we use the :py:meth:`Event.objects.create_event 79 | ` method. To create an 80 | event for the "photo-tag" group, we just need to know the source of 81 | the event, what entities are involved, and some information about what 82 | happened 83 | 84 | .. code-block:: python 85 | 86 | from entity_event import Event 87 | 88 | # Assume we're within the photo tag processing code, and we'll 89 | # have access to variables entities_tagged, photo_owner, and 90 | # photo_location 91 | 92 | Event.objects.create_event( 93 | source=photo_source, 94 | actors=entities_tagged, 95 | context={ 96 | 'photo_owner': photo_owner 97 | 'photo_location': photo_location 98 | } 99 | ) 100 | 101 | The code above is all that's required to store an event. While this is 102 | a fairly simple interface for creating events, in some applications it 103 | may be easier to read, and less intrusive in application code to use 104 | django-signals in the application code, and create events in signal 105 | handlers. In either case, We're ready to discuss subscription 106 | management. 107 | 108 | 109 | Managing Mediums and Subscriptions to Events 110 | -------------------------------------------- 111 | 112 | Once the events are created, we need to define how the users of our 113 | application are going to interact with the events. There are a large 114 | number of possible ways to notify users of events. Email, newsfeeds, 115 | notification bars, are all examples. Django Entity Event doesn't 116 | handle the display logic for notifying users, but it does handle the 117 | subscription and event routing/querying logic that determines which 118 | events go where. 119 | 120 | To start, we must define a :py:class:`~entity_event.models.Medium` 121 | object for each method our users will consume events from. Storing 122 | :py:class:`~entity_event.models.Medium` objects in the database has 123 | two purposes. First, it is referenced when subscriptions are 124 | created. Second the :py:class:`~entity_event.models.Medium` objects 125 | provide an entry point to query for events and have all the 126 | subscription logic and filtering taken care of for you. 127 | 128 | Like :py:class:`~entity_event.models.Source` objects, 129 | :py:class:`~entity_event.models.Medium` objects are simple to create 130 | 131 | .. code-block:: python 132 | 133 | from entity_event import Medium 134 | 135 | email_medium = Medium.objects.create( 136 | name="email", 137 | display_name="Email", 138 | description="Email Notifications" 139 | ) 140 | 141 | newsfeed_medium = Medium.objects.create( 142 | name="newsfeed", 143 | display_name="NewsFeed", 144 | description="Your personal feed of events" 145 | ) 146 | 147 | At first, none of the events we have been creating are accessible by 148 | either of these mediums. In order for the mediums to have access to 149 | the events, an appropriate 150 | :py:class:`~entity_event.models.Subscription` object needs to be 151 | created. Creating a :py:class:`~entity_event.models.Subscription` 152 | object encodes that an entity, or group of entities, wants to receive 153 | notifications of events from a given source, by a given medium. For 154 | example, we can create a subscription so that all the sub-entities of 155 | an ``all_users`` entity will receive notifications of new products in 156 | their newsfeed 157 | 158 | .. code-block:: python 159 | 160 | from entity import EntityKind 161 | from entity_event import Subscription 162 | 163 | Subscription.objects.create( 164 | medium=newsfeed_medium, 165 | source=product_source, 166 | entity=all_users, 167 | sub_entity_kind=EntityKind.objects.get(name='user'), 168 | only_following=False 169 | ) 170 | 171 | With this :py:class:`~entity_event.models.Subscription` object 172 | defined, all events from the new product source will be available to 173 | the newsfeed medium. 174 | 175 | If we wanted to create a subscription for users to get email 176 | notifications when they've been tagged in a photo, we will also create 177 | a :py:class:`~entity_event.models.Subscription` object. However, 178 | unlike the new product events, not every event from the photos source 179 | is relevant to every user. We want to limit the events they receive 180 | emails about to the events where they are tagged in the photo. 181 | 182 | In code above, you may notice the ``only_following=False`` 183 | argument. This argument controls whether all events are relevant for 184 | the subscription, or if the events are only relevant if they are 185 | related to the entities being subscribed. Since new products are 186 | relevant to all users, we set this to ``False``. To create a 187 | subscription for users to receive emails about photos they're tagged 188 | in, we'll define the subscription as follows 189 | 190 | .. code-block:: python 191 | 192 | Subscription.objects.create( 193 | medium=email_medium, 194 | source=photo_source, 195 | entity=all_users, 196 | sub_entity_kind=EntityKind.objects.get(name='user'), 197 | only_following=True 198 | ) 199 | 200 | This will only notify users if an entity they're following is tagged 201 | in a photo. By default, entities follow themselves and their super 202 | entities. 203 | 204 | Creating subscriptions for a whole group of people with a single entry 205 | into the database is very powerful. However, some users may wish to 206 | opt out of certain types of notifications. To accommodate this, we can 207 | create an :py:class:`~entity_event.models.Unsubscription` 208 | object. These are used to unsubscribe a single entity from receiving 209 | notifications of a given source on a given medium. For example if a 210 | user wants to opt out of new product notifications in their newsfeed, 211 | we can create an :py:class:`~entity_event.models.Unsubscription` 212 | object for them 213 | 214 | .. code-block:: python 215 | 216 | from entity_event import Unsubscription 217 | 218 | # Assume we have an entity, unsubscriber who wants to unsubscribe 219 | Unsubscription.objects.create( 220 | entity=unsubscriber, 221 | source=product_source, 222 | medium=newsfeed_medium 223 | ) 224 | 225 | Once this object is stored in the database, this user will no longer 226 | receive this type of notification. 227 | 228 | Once we have :py:class:`~entity_event.models.Medium` objects set up 229 | for the methods of sending notifications, and we have our entities 230 | subscribed to sources of events on those mediums, we can use the 231 | :py:class:`~entity_event.models.Medium` objects to query for events, 232 | which we can then display to our users. 233 | 234 | 235 | Querying Events 236 | --------------- 237 | 238 | Once we've got events being created, and subscriptions to them for a 239 | given medium, we'll want to display those events to our users. When 240 | there are a large variety of events coming into the system from many 241 | different sources, it would be very difficult to query the 242 | :py:class:`~entity_event.models.Event` model directly while still 243 | respecting all the :py:class:`~entity_event.models.Subscription` logic 244 | that we hope to maintain. 245 | 246 | For this reason, Django Entity Event provides three methods to make 247 | querying for events` to display extremely simple. Since the 248 | :py:class:`~entity_event.models.Medium` objects you've created should 249 | correspond directly to a means by which you want to display events to 250 | users, there are three methods of the 251 | :py:class:`~entity_event.models.Medium` class to perform queries. 252 | 253 | 1. :py:meth:`Medium.events ` 254 | 2. :py:meth:`Medium.entity_events ` 255 | 3. :py:meth:`Medium.events_targets ` 256 | 257 | Each of these methods return somewhat different views into the events 258 | that are being stored in the system. In each case, though, you will 259 | call these methods from an instance of 260 | :py:class:`~entity_event.models.Medium`, and the events returned will 261 | only be events for which there is a corresponding 262 | :py:class:`~entity_event.models.Subscription` object. 263 | 264 | The :py:meth:`Medium.events ` 265 | method can be used to return all the events for that medium. This 266 | method is useful for mediums that want to display events without any 267 | particular regard for who performed the events. For example, we could 268 | have a medium that aggregated all of the events from the new products 269 | source. If we had a medium, ``all_products_medium``, with the 270 | appropriate subscriptions set up, getting all the new product events 271 | is as simple as 272 | 273 | .. code-block:: python 274 | 275 | all_products_medium.events() 276 | 277 | The :py:meth:`Medium.entity_events 278 | ` method can be used to get 279 | all the events for a given entity on that medium. It takes a single 280 | entity as an argument, and returns all the events for that entity on 281 | that medium. We could use this method to get events for an individual 282 | entity's newsfeed. If we have a large number of sources creating 283 | events, with subscriptions between those sources and the newsfeed, 284 | aggregating them into one QuerySet of events is as simple as 285 | 286 | .. code-block:: python 287 | 288 | newsfeed_medium.entity_events(user_entity) 289 | 290 | There are some mediums that notify users of events independent of a 291 | pageview's request/response cycle. For example, an email medium will 292 | want to process batches of events, and need information about who to 293 | send the events to. For this use case, the 294 | :py:meth:`Medium.events_targets 295 | ` method can be 296 | used. Instead of providing a ``EventQueryset``, it provides a list of 297 | tuples in the form ``(event, targets)``, where ``targets`` is a list 298 | of the entities that should receive that notification. We could use 299 | this function to send emails about events as follows 300 | 301 | .. code-block:: python 302 | 303 | from django.core.mail import send_mail 304 | 305 | new_emails = email_medium.events_targets(seen=False, mark_seen=True) 306 | 307 | for event, targets in new_emails: 308 | send_mail( 309 | subject = event.context["subject"] 310 | message = event.context["message"] 311 | recipient_list = [t.entity_meta["email"] for t in targets] 312 | ) 313 | 314 | As seen in the last example, these methods also support a number of 315 | arguments for filtering the events based on properties of the events 316 | themselves. All three methods support the following arguments: 317 | 318 | - ``start_time``: providing a datetime object to this parameter will 319 | filter the events to only those that occurred at or after this time. 320 | - ``end_time``: providing a datetime object to this parameter will 321 | filter the events to only those that occurred at or before this time. 322 | - ``seen``: passing ``False`` to this argument will filter the events 323 | to only those which have not been marked as having been seen. 324 | - ``include_expired``: defaults to ``False``, passing ``True`` to this 325 | argument will include events that are expired. Events with 326 | expiration are discussed in 327 | :py:meth:`~entity_event.models.EventManager.create_event`. 328 | - ``actor``: providing an entity to this parameter will filter the 329 | events to only those that include the given entity as an actor. 330 | 331 | Finally, all of these methods take an argument ``mark_seen``. Passing 332 | ``True`` to this argument will mark the events as having been seen by 333 | that medium so they will not show up if ``False`` is passed to the 334 | ``seen`` filtering argument. 335 | 336 | Using these three methods with any combination of the event filters 337 | should make virtually any event querying task simple. 338 | -------------------------------------------------------------------------------- /entity_event/tests/context_loader_tests.py: -------------------------------------------------------------------------------- 1 | from django import VERSION 2 | from django.test import TestCase 3 | from django.test.utils import override_settings 4 | from django_dynamic_fixture import N, G 5 | from unittest.mock import patch 6 | 7 | from entity_event import context_loader 8 | from entity_event import models 9 | from entity_event.tests import models as test_models 10 | 11 | 12 | class TestGetDefaultRenderingStyle(TestCase): 13 | def test_none_defined(self): 14 | self.assertIsNone(context_loader.get_default_rendering_style()) 15 | 16 | @override_settings(DEFAULT_ENTITY_EVENT_RENDERING_STYLE='short') 17 | def test_defined(self): 18 | rs = G(models.RenderingStyle, name='short') 19 | self.assertEqual(context_loader.get_default_rendering_style(), rs) 20 | 21 | 22 | class TestGetContextHintsFromSource(TestCase): 23 | def test_no_context_renderers(self): 24 | res = context_loader.get_context_hints_per_source([]) 25 | self.assertEqual(res, {}) 26 | 27 | @patch.object(models.ContextRenderer, 'get_sources', spec_set=True) 28 | def test_one_context_renderer(self, mock_get_sources): 29 | source = N(models.Source, id=1) 30 | mock_get_sources.return_value = [source] 31 | res = context_loader.get_context_hints_per_source([ 32 | N(models.ContextRenderer, source=source, context_hints={ 33 | 'key': { 34 | 'app_name': 'entity_event.tests', 35 | 'model_name': 'TestModel', 36 | 'select_related': ['fk'], 37 | }, 38 | }) 39 | ]) 40 | self.assertEqual(res, { 41 | source: { 42 | 'key': { 43 | 'app_name': 'entity_event.tests', 44 | 'model_name': 'TestModel', 45 | 'select_related': set(['fk']), 46 | 'prefetch_related': set(), 47 | } 48 | } 49 | }) 50 | 51 | def test_multiple_context_renderers_over_multiple_source(self): 52 | source1 = N(models.Source, id=1) 53 | source2 = N(models.Source, id=2) 54 | res = context_loader.get_context_hints_per_source([ 55 | N(models.ContextRenderer, source=source1, context_hints={ 56 | 'key': { 57 | 'app_name': 'entity_event.tests', 58 | 'model_name': 'TestModel', 59 | 'select_related': ['fk'], 60 | 'prefetch_related': ['prefetch1', 'prefetch2'], 61 | }, 62 | }), 63 | N(models.ContextRenderer, source=source1, context_hints={ 64 | 'key': { 65 | 'app_name': 'entity_event.tests', 66 | 'model_name': 'TestModel', 67 | 'select_related': ['fk1'], 68 | 'prefetch_related': ['prefetch2', 'prefetch3'], 69 | }, 70 | }), 71 | N(models.ContextRenderer, source=source2, context_hints={ 72 | 'key2': { 73 | 'app_name': 'entity_event.tests2', 74 | 'model_name': 'TestModel2', 75 | 'select_related': ['fk2'], 76 | 'prefetch_related': ['prefetch5', 'prefetch6'], 77 | }, 78 | }) 79 | ]) 80 | self.assertEqual(res, { 81 | source1: { 82 | 'key': { 83 | 'app_name': 'entity_event.tests', 84 | 'model_name': 'TestModel', 85 | 'select_related': set(['fk', 'fk1']), 86 | 'prefetch_related': set(['prefetch1', 'prefetch2', 'prefetch3']), 87 | } 88 | }, 89 | source2: { 90 | 'key2': { 91 | 'app_name': 'entity_event.tests2', 92 | 'model_name': 'TestModel2', 93 | 'select_related': set(['fk2']), 94 | 'prefetch_related': set(['prefetch5', 'prefetch6']), 95 | } 96 | }, 97 | }) 98 | 99 | 100 | class TestGetQuerysetsForContextHints(TestCase): 101 | def test_no_context_hints(self): 102 | qsets = context_loader.get_querysets_for_context_hints({}) 103 | self.assertEqual(qsets, {}) 104 | 105 | def test_one_context_hint_no_select_related(self): 106 | source = N(models.Source, id=1) 107 | qsets = context_loader.get_querysets_for_context_hints({ 108 | source: { 109 | 'key': { 110 | 'app_name': 'tests', 111 | 'model_name': 'TestModel', 112 | }, 113 | }, 114 | }) 115 | self.assertEqual(qsets, { 116 | test_models.TestModel: test_models.TestModel.objects 117 | }) 118 | 119 | def test_one_context_hint_w_select_related(self): 120 | source = N(models.Source, id=1) 121 | qsets = context_loader.get_querysets_for_context_hints({ 122 | source: { 123 | 'key': { 124 | 'app_name': 'tests', 125 | 'model_name': 'TestModel', 126 | 'select_related': ['fk'], 127 | }, 128 | }, 129 | }) 130 | # Verify the raw sql to ensure select relateds will happen 131 | expected_sql = ( 132 | 'SELECT "tests_testmodel"."id", "tests_testmodel"."value", "tests_testmodel"."fk_id", ' 133 | '"tests_testmodel"."fk2_id", ' 134 | '"tests_testfkmodel"."id", "tests_testfkmodel"."value" FROM "tests_testmodel" INNER JOIN ' 135 | '"tests_testfkmodel" ON ("tests_testmodel"."fk_id" = "tests_testfkmodel"."id")' 136 | ) 137 | actual_sql = str(qsets[test_models.TestModel].query) 138 | 139 | # Django < 1.7 and 1.8 have spaces before/after parentheses 140 | actual_sql = actual_sql.replace('( ', '(').replace(' )', ')') 141 | 142 | self.assertEqual(actual_sql, expected_sql) 143 | 144 | def test_multiple_context_hints_w_multiple_select_related(self): 145 | source = N(models.Source, id=1) 146 | source2 = N(models.Source, id=2) 147 | qsets = context_loader.get_querysets_for_context_hints({ 148 | source: { 149 | 'key': { 150 | 'app_name': 'tests', 151 | 'model_name': 'TestModel', 152 | 'select_related': ['fk'], 153 | }, 154 | }, 155 | source2: { 156 | 'key2': { 157 | 'app_name': 'tests', 158 | 'model_name': 'TestModel', 159 | 'select_related': ['fk2'], 160 | }, 161 | } 162 | }) 163 | # Verify the raw sql to ensure select relateds will happen 164 | expected_sql = ( 165 | 'SELECT "tests_testmodel"."id", "tests_testmodel"."value", "tests_testmodel"."fk_id", ' 166 | '"tests_testmodel"."fk2_id", ' 167 | '"tests_testfkmodel"."id", "tests_testfkmodel"."value", ' 168 | '"tests_testfkmodel2"."id", "tests_testfkmodel2"."value" FROM "tests_testmodel" INNER JOIN ' 169 | '"tests_testfkmodel" ON ("tests_testmodel"."fk_id" = "tests_testfkmodel"."id") ' 170 | 'INNER JOIN "tests_testfkmodel2" ON ("tests_testmodel"."fk2_id" = "tests_testfkmodel2"."id")' 171 | ) 172 | 173 | actual_sql = str(qsets[test_models.TestModel].query) 174 | 175 | # Django < 1.7 and 1.8 have spaces before/after parentheses 176 | actual_sql = actual_sql.replace('( ', '(').replace(' )', ')') 177 | 178 | self.assertEqual(actual_sql, expected_sql) 179 | 180 | def test_multiple_context_hints_w_multiple_select_related_multiple_prefetch_related(self): 181 | source = N(models.Source, id=1) 182 | source2 = N(models.Source, id=2) 183 | qsets = context_loader.get_querysets_for_context_hints({ 184 | source: { 185 | 'key': { 186 | 'app_name': 'tests', 187 | 'model_name': 'TestModel', 188 | 'select_related': ['fk'], 189 | 'prefetch_related': ['fk_m2m'], 190 | }, 191 | }, 192 | source2: { 193 | 'key2': { 194 | 'app_name': 'tests', 195 | 'model_name': 'TestModel', 196 | 'select_related': ['fk2'], 197 | }, 198 | } 199 | }) 200 | 201 | # Verify the raw sql to ensure select relateds will happen. Note that prefetch relateds are not 202 | # included in raw sql 203 | expected_sql = ( 204 | 'SELECT "tests_testmodel"."id", "tests_testmodel"."value", "tests_testmodel"."fk_id", ' 205 | '"tests_testmodel"."fk2_id", ' 206 | '"tests_testfkmodel"."id", "tests_testfkmodel"."value", ' 207 | '"tests_testfkmodel2"."id", "tests_testfkmodel2"."value" FROM "tests_testmodel" INNER JOIN ' 208 | '"tests_testfkmodel" ON ("tests_testmodel"."fk_id" = "tests_testfkmodel"."id") ' 209 | 'INNER JOIN "tests_testfkmodel2" ON ("tests_testmodel"."fk2_id" = "tests_testfkmodel2"."id")' 210 | ) 211 | 212 | actual_sql = str(qsets[test_models.TestModel].query) 213 | 214 | # Django < 1.7 and 1.8 have spaces before/after parentheses 215 | actual_sql = actual_sql.replace('( ', '(').replace(' )', ')') 216 | 217 | self.assertEqual(actual_sql, expected_sql) 218 | 219 | 220 | class TestGetQuerysetsForContextHintsDbTests(TestCase): 221 | def test_multiple_context_hints_w_multiple_select_related_multiple_prefetch_related(self): 222 | source = N(models.Source, id=1) 223 | source2 = N(models.Source, id=2) 224 | qsets = context_loader.get_querysets_for_context_hints({ 225 | source: { 226 | 'key': { 227 | 'app_name': 'tests', 228 | 'model_name': 'TestModel', 229 | 'select_related': ['fk'], 230 | 'prefetch_related': ['fk_m2m'], 231 | }, 232 | }, 233 | source2: { 234 | 'key2': { 235 | 'app_name': 'tests', 236 | 'model_name': 'TestModel', 237 | 'select_related': ['fk2'], 238 | }, 239 | } 240 | }) 241 | 242 | # Create objects to query in order to test optimal number of queries 243 | fk = G(test_models.TestFKModel) 244 | fk2 = G(test_models.TestFKModel2) 245 | o = G(test_models.TestModel, fk=fk, fk2=fk2) 246 | m2ms = [G(test_models.TestFKModel), G(test_models.TestFKModel)] 247 | o.fk_m2m.add(*m2ms) 248 | 249 | with self.assertNumQueries(2): 250 | v = qsets[test_models.TestModel].get(id=o.id) 251 | self.assertEqual(v.fk, fk) 252 | self.assertEqual(v.fk2, fk2) 253 | self.assertEqual(set(v.fk_m2m.all()), set(m2ms)) 254 | 255 | 256 | class DictFindTest(TestCase): 257 | def test_dict_find_none(self): 258 | self.assertEqual(list(context_loader.dict_find({}, 'key')), []) 259 | 260 | def test_dict_find_list_key(self): 261 | d = {'key': ['value']} 262 | self.assertEqual(list(context_loader.dict_find(d, 'key')), [(d, ['value'])]) 263 | 264 | def test_dict_find_nested_list_key(self): 265 | d = {'key': ['value']} 266 | larger_dict = { 267 | 'l': [{ 268 | 'hi': {}, 269 | }, { 270 | 'hi2': d 271 | }] 272 | } 273 | self.assertEqual(list(context_loader.dict_find(larger_dict, 'key')), [(d, ['value'])]) 274 | 275 | def test_dict_find_double_nested_list_key(self): 276 | d = {'key': ['value']} 277 | larger_dict = { 278 | 'l': [{ 279 | 'hi': {}, 280 | }, { 281 | 'hi2': d 282 | }], 283 | 'hi3': d 284 | } 285 | self.assertEqual(list(context_loader.dict_find(larger_dict, 'key')), [(d, ['value']), (d, ['value'])]) 286 | 287 | def test_dict_find_deep_nested_list_key(self): 288 | d = {'key': ['value']} 289 | larger_dict = [[{ 290 | 'l': [{ 291 | 'hi': {}, 292 | }, { 293 | 'hi2': d 294 | }], 295 | 'hi3': d 296 | }]] 297 | self.assertEqual(list(context_loader.dict_find(larger_dict, 'key')), [(d, ['value']), (d, ['value'])]) 298 | 299 | 300 | class GetModelIdsToFetchTest(TestCase): 301 | def test_no_events(self): 302 | self.assertEqual(context_loader.get_model_ids_to_fetch([], {}), {}) 303 | 304 | def test_no_context_hints(self): 305 | e = N(models.Event, id=1, context={}) 306 | self.assertEqual(context_loader.get_model_ids_to_fetch([e], {}), {}) 307 | 308 | def test_w_one_event_one_context_hint_single_pk(self): 309 | source = N(models.Source, id=1) 310 | hints = { 311 | source: { 312 | 'key': { 313 | 'app_name': 'tests', 314 | 'model_name': 'TestModel', 315 | }, 316 | }, 317 | } 318 | e = N(models.Event, context={'key': 2}, source=source) 319 | self.assertEqual(context_loader.get_model_ids_to_fetch([e], hints), { 320 | test_models.TestModel: set([2]) 321 | }) 322 | 323 | def test_w_one_event_one_context_hint_list_pks(self): 324 | source = N(models.Source, id=1) 325 | hints = { 326 | source: { 327 | 'key': { 328 | 'app_name': 'tests', 329 | 'model_name': 'TestModel', 330 | }, 331 | }, 332 | } 333 | e = N(models.Event, context={'key': [2, 3, 5]}, source=source) 334 | self.assertEqual(context_loader.get_model_ids_to_fetch([e], hints), { 335 | test_models.TestModel: set([2, 3, 5]) 336 | }) 337 | 338 | def test_w_multiple_events_one_context_hint_list_pks(self): 339 | source = N(models.Source, id=1) 340 | hints = { 341 | source: { 342 | 'key': { 343 | 'app_name': 'tests', 344 | 'model_name': 'TestModel', 345 | }, 346 | }, 347 | } 348 | e1 = N(models.Event, context={'key': [2, 3, 5]}, source=source) 349 | e2 = N(models.Event, context={'key': 88}, source=source) 350 | self.assertEqual(context_loader.get_model_ids_to_fetch([e1, e2], hints), { 351 | test_models.TestModel: set([2, 3, 5, 88]) 352 | }) 353 | 354 | def test_w_multiple_events_multiple_context_hints_list_pks(self): 355 | source1 = N(models.Source, id=1) 356 | source2 = N(models.Source, id=2) 357 | hints = { 358 | source1: { 359 | 'key': { 360 | 'app_name': 'tests', 361 | 'model_name': 'TestModel', 362 | }, 363 | }, 364 | source2: { 365 | 'key': { 366 | 'app_name': 'tests', 367 | 'model_name': 'TestModel', 368 | }, 369 | 'key2': { 370 | 'app_name': 'tests', 371 | 'model_name': 'TestFKModel', 372 | }, 373 | }, 374 | } 375 | e1 = N(models.Event, context={'key': [2, 3, 5]}, source=source1) 376 | e2 = N(models.Event, context={'key': 88}, source=source1) 377 | e3 = N(models.Event, context={'key': 100, 'key2': [50]}, source=source2) 378 | e4 = N(models.Event, context={'key2': [60]}, source=source2) 379 | self.assertEqual(context_loader.get_model_ids_to_fetch([e1, e2, e3, e4], hints), { 380 | test_models.TestModel: set([2, 3, 5, 88, 100]), 381 | test_models.TestFKModel: set([50, 60]) 382 | }) 383 | 384 | 385 | class FetchModelDataTest(TestCase): 386 | def test_none(self): 387 | self.assertEqual({}, context_loader.fetch_model_data({}, {})) 388 | 389 | def test_one_model_one_id_to_fetch(self): 390 | m1 = G(test_models.TestModel) 391 | self.assertEqual({ 392 | test_models.TestModel: {m1.id: m1} 393 | }, context_loader.fetch_model_data({ 394 | test_models.TestModel: test_models.TestModel.objects 395 | }, { 396 | test_models.TestModel: [m1.id] 397 | })) 398 | 399 | def test_multiple_models_multiple_ids_to_fetch(self): 400 | m1 = G(test_models.TestModel) 401 | m2 = G(test_models.TestModel) 402 | m3 = G(test_models.TestFKModel) 403 | self.assertEqual({ 404 | test_models.TestModel: {m1.id: m1, m2.id: m2}, 405 | test_models.TestFKModel: {m3.id: m3} 406 | }, context_loader.fetch_model_data({ 407 | test_models.TestModel: test_models.TestModel.objects, 408 | test_models.TestFKModel: test_models.TestFKModel.objects, 409 | }, { 410 | test_models.TestModel: [m1.id, m2.id], 411 | test_models.TestFKModel: [m3.id], 412 | })) 413 | 414 | 415 | class LoadFetchedObjectsIntoContextsTest(TestCase): 416 | def test_none(self): 417 | context_loader.load_fetched_objects_into_contexts([], {}, {}) 418 | 419 | def test_event_with_no_model_data(self): 420 | e = N(models.Event, id=1, context={'hi', 'hi'}) 421 | context_loader.load_fetched_objects_into_contexts([e], {}, {}) 422 | self.assertEqual(e, e) 423 | 424 | def test_one_event_w_model_data(self): 425 | m = N(test_models.TestModel, id=2) 426 | s = N(models.Source, id=1) 427 | hints = { 428 | s: { 429 | 'key': { 430 | 'model_name': 'TestModel', 431 | 'app_name': 'tests', 432 | } 433 | } 434 | } 435 | e = N(models.Event, context={'key': m.id}, source=s) 436 | context_loader.load_fetched_objects_into_contexts([e], {test_models.TestModel: {m.id: m}}, hints) 437 | self.assertEqual(e.context, {'key': m}) 438 | 439 | def test_one_event_w_list_model_data(self): 440 | m1 = N(test_models.TestModel, id=2) 441 | m2 = N(test_models.TestModel, id=3) 442 | s = N(models.Source, id=1) 443 | hints = { 444 | s: { 445 | 'key': { 446 | 'model_name': 'TestModel', 447 | 'app_name': 'tests', 448 | } 449 | } 450 | } 451 | e = N(models.Event, context={'key': [m1.id, m2.id]}, source=s) 452 | context_loader.load_fetched_objects_into_contexts([e], {test_models.TestModel: {m1.id: m1, m2.id: m2}}, hints) 453 | self.assertEqual(e.context, {'key': [m1, m2]}) 454 | 455 | 456 | class TestLoadRenderersIntoEvents(TestCase): 457 | def test_no_mediums_or_renderers(self): 458 | events = [N(models.Event, context={})] 459 | context_loader.load_renderers_into_events(events, [], [], None) 460 | self.assertEqual(events[0]._context_renderers, {}) 461 | 462 | def test_mediums_and_no_renderers(self): 463 | events = [N(models.Event, context={})] 464 | mediums = [N(models.Medium)] 465 | context_loader.load_renderers_into_events(events, mediums, [], None) 466 | self.assertEqual(events[0]._context_renderers, {}) 467 | 468 | def test_mediums_w_renderers(self): 469 | s1 = N(models.Source, id=1) 470 | s2 = N(models.Source, id=2) 471 | e1 = N(models.Event, context={}, source=s1) 472 | e2 = N(models.Event, context={}, source=s2) 473 | rg1 = N(models.RenderingStyle, id=1) 474 | rg2 = N(models.RenderingStyle, id=2) 475 | m1 = N(models.Medium, id=1, rendering_style=rg1) 476 | m2 = N(models.Medium, id=2, rendering_style=rg2) 477 | cr1 = N(models.ContextRenderer, source=s1, rendering_style=rg1, id=1) 478 | cr2 = N(models.ContextRenderer, source=s2, rendering_style=rg1, id=2) 479 | cr3 = N(models.ContextRenderer, source=s1, rendering_style=rg2, id=3) 480 | 481 | context_loader.load_renderers_into_events([e1, e2], [m1, m2], [cr1, cr2, cr3], None) 482 | 483 | self.assertEqual(e1._context_renderers, { 484 | m1: cr1, 485 | m2: cr3, 486 | }) 487 | self.assertEqual(e2._context_renderers, { 488 | m1: cr2 489 | }) 490 | 491 | def test_mediums_w_source_group_renderers(self): 492 | s1 = N(models.Source, id=1, group=N(models.SourceGroup, id=1)) 493 | s2 = N(models.Source, id=2, group=N(models.SourceGroup, id=1)) 494 | e1 = N(models.Event, context={}, source=s1) 495 | e2 = N(models.Event, context={}, source=s2) 496 | rs1 = N(models.RenderingStyle, id=1) 497 | rs2 = N(models.RenderingStyle, id=2) 498 | m1 = N(models.Medium, id=1, rendering_style=rs1) 499 | m2 = N(models.Medium, id=2, rendering_style=rs2) 500 | cr1 = N(models.ContextRenderer, source_group=s1.group, rendering_style=rs1, id=1) 501 | cr2 = N(models.ContextRenderer, source_group=s1.group, rendering_style=rs2, id=2) 502 | 503 | context_loader.load_renderers_into_events([e1, e2], [m1, m2], [cr1, cr2], None) 504 | 505 | self.assertEqual(e1._context_renderers, { 506 | m1: cr1, 507 | m2: cr2, 508 | }) 509 | self.assertEqual(e2._context_renderers, { 510 | m1: cr1, 511 | m2: cr2, 512 | }) 513 | 514 | def test_mediums_w_source_group_renderers_default(self): 515 | s1 = N(models.Source, id=1, group=N(models.SourceGroup, id=1)) 516 | s2 = N(models.Source, id=2, group=N(models.SourceGroup, id=1)) 517 | e1 = N(models.Event, context={}, source=s1) 518 | e2 = N(models.Event, context={}, source=s2) 519 | rs1 = N(models.RenderingStyle, id=1) 520 | rs2 = N(models.RenderingStyle, id=2) 521 | m1 = N(models.Medium, id=1, rendering_style=rs2) 522 | m2 = N(models.Medium, id=2, rendering_style=rs2) 523 | cr1 = N(models.ContextRenderer, source_group=s1.group, rendering_style=rs1, id=1) 524 | 525 | context_loader.load_renderers_into_events([e1, e2], [m1, m2], [cr1], rs1) 526 | 527 | self.assertEqual(e1._context_renderers, { 528 | m1: cr1, 529 | m2: cr1, 530 | }) 531 | self.assertEqual(e2._context_renderers, { 532 | m1: cr1, 533 | m2: cr1, 534 | }) 535 | 536 | def test_mediums_w_renderers_default_source(self): 537 | s1 = N(models.Source, id=1) 538 | s2 = N(models.Source, id=2) 539 | e1 = N(models.Event, context={}, source=s1) 540 | e2 = N(models.Event, context={}, source=s2) 541 | rs1 = N(models.RenderingStyle, id=1) 542 | rs2 = N(models.RenderingStyle, id=2) 543 | m1 = N(models.Medium, id=1, rendering_style=rs1) 544 | m2 = N(models.Medium, id=2, rendering_style=rs1) 545 | cr1 = N(models.ContextRenderer, source=s1, rendering_style=rs1, id=1) 546 | cr2 = N(models.ContextRenderer, source=s2, rendering_style=rs1, id=2) 547 | cr3 = N(models.ContextRenderer, source=s1, rendering_style=rs2, id=3) 548 | 549 | default_style = rs1 550 | 551 | context_loader.load_renderers_into_events([e1, e2], [m1, m2], [cr1, cr2, cr3], default_style) 552 | 553 | self.assertEqual(e1._context_renderers, { 554 | m1: cr1, 555 | m2: cr1, 556 | }) 557 | self.assertEqual(e2._context_renderers, { 558 | m1: cr2, 559 | m2: cr2, 560 | }) 561 | 562 | 563 | class LoadContextsAndRenderersTest(TestCase): 564 | """ 565 | Integration tests for loading contexts and renderers into events. 566 | """ 567 | def test_none(self): 568 | context_loader.load_contexts_and_renderers([], []) 569 | 570 | def test_no_mediums(self): 571 | e = G(models.Event, context={}) 572 | context_loader.load_contexts_and_renderers([e], []) 573 | self.assertEqual(e.context, {}) 574 | 575 | def test_one_render_target_one_event(self): 576 | m1 = G(test_models.TestModel) 577 | s = G(models.Source) 578 | rg = G(models.RenderingStyle) 579 | e = G(models.Event, context={'key': m1.id}, source=s) 580 | medium = G(models.Medium, rendering_style=rg) 581 | G(models.ContextRenderer, rendering_style=rg, source=s, context_hints={ 582 | 'key': { 583 | 'model_name': 'TestModel', 584 | 'app_name': 'tests', 585 | } 586 | }) 587 | 588 | context_loader.load_contexts_and_renderers([e], [medium]) 589 | self.assertEqual(e.context, {'key': m1}) 590 | 591 | @override_settings(DEFAULT_ENTITY_EVENT_RENDERING_STYLE='short') 592 | def test_one_render_target_one_event_no_style_with_default(self): 593 | m1 = G(test_models.TestModel) 594 | s = G(models.Source) 595 | rs = G(models.RenderingStyle, name='short') 596 | e = G(models.Event, context={'key': m1.id}, source=s) 597 | medium = G(models.Medium, rendering_style=None) 598 | G(models.ContextRenderer, rendering_style=rs, source=s, context_hints={ 599 | 'key': { 600 | 'model_name': 'TestModel', 601 | 'app_name': 'tests', 602 | } 603 | }) 604 | 605 | context_loader.load_contexts_and_renderers([e], [medium]) 606 | self.assertEqual(e.context, {'key': m1}) 607 | 608 | def test_multiple_render_targets_multiple_events(self): 609 | test_m1 = G(test_models.TestModel) 610 | test_m2 = G(test_models.TestModel) 611 | test_m3 = G(test_models.TestModel) 612 | test_fk_m1 = G(test_models.TestFKModel) 613 | test_fk_m2 = G(test_models.TestFKModel) 614 | s1 = G(models.Source) 615 | s2 = G(models.Source) 616 | rg1 = G(models.RenderingStyle) 617 | rg2 = G(models.RenderingStyle) 618 | medium1 = G(models.Medium, rendering_style=rg1) 619 | medium2 = G(models.Medium, rendering_style=rg2) 620 | 621 | cr1 = G(models.ContextRenderer, rendering_style=rg1, source=s1, context_hints={ 622 | 'key': { 623 | 'model_name': 'TestModel', 624 | 'app_name': 'tests', 625 | } 626 | }) 627 | cr2 = G(models.ContextRenderer, rendering_style=rg2, source=s2, context_hints={ 628 | 'key': { 629 | 'model_name': 'TestModel', 630 | 'app_name': 'tests', 631 | }, 632 | 'key2': { 633 | 'model_name': 'TestFKModel', 634 | 'app_name': 'tests', 635 | } 636 | }) 637 | 638 | e1 = G(models.Event, context={'key': test_m1.id, 'key2': 'haha'}, source=s1) 639 | e2 = G(models.Event, context={'key': [test_m2.id, test_m3.id]}, source=s1) 640 | e3 = G(models.Event, context={'key2': test_fk_m1.id, 'key': test_m1.id}, source=s2) 641 | e4 = G(models.Event, context={'key2': test_fk_m2.id}, source=s2) 642 | 643 | context_loader.load_contexts_and_renderers([e1, e2, e3, e4], [medium1, medium2]) 644 | self.assertEqual(e1.context, {'key': test_m1, 'key2': 'haha'}) 645 | self.assertEqual(e2.context, {'key': [test_m2, test_m3]}) 646 | self.assertEqual(e3.context, {'key2': test_fk_m1, 'key': test_m1}) 647 | self.assertEqual(e4.context, {'key2': test_fk_m2}) 648 | 649 | # Verify context renderers are put into the events properly 650 | self.assertEqual(e1._context_renderers, { 651 | medium1: cr1, 652 | }) 653 | self.assertEqual(e2._context_renderers, { 654 | medium1: cr1, 655 | }) 656 | self.assertEqual(e3._context_renderers, { 657 | medium2: cr2, 658 | }) 659 | self.assertEqual(e4._context_renderers, { 660 | medium2: cr2, 661 | }) 662 | 663 | @override_settings(DEFAULT_ENTITY_EVENT_RENDERING_STYLE='short') 664 | def test_multiple_render_targets_multiple_events_use_default(self): 665 | """ 666 | Tests the case when a context renderer is not available for a rendering style 667 | but the default style is used instead. 668 | """ 669 | test_m1 = G(test_models.TestModel) 670 | test_m2 = G(test_models.TestModel) 671 | test_m3 = G(test_models.TestModel) 672 | test_fk_m1 = G(test_models.TestFKModel) 673 | test_fk_m2 = G(test_models.TestFKModel) 674 | s1 = G(models.Source) 675 | s2 = G(models.Source) 676 | rs1 = G(models.RenderingStyle, name='short') 677 | rs2 = G(models.RenderingStyle) 678 | medium1 = G(models.Medium, rendering_style=rs1) 679 | medium2 = G(models.Medium, rendering_style=rs2) 680 | 681 | cr1 = G(models.ContextRenderer, rendering_style=rs1, source=s1, context_hints={ 682 | 'key': { 683 | 'model_name': 'TestModel', 684 | 'app_name': 'tests', 685 | } 686 | }) 687 | cr2 = G(models.ContextRenderer, rendering_style=rs1, source=s2, context_hints={ 688 | 'key': { 689 | 'model_name': 'TestModel', 690 | 'app_name': 'tests', 691 | }, 692 | 'key2': { 693 | 'model_name': 'TestFKModel', 694 | 'app_name': 'tests', 695 | } 696 | }) 697 | 698 | e1 = G(models.Event, context={'key': test_m1.id, 'key2': 'haha'}, source=s1) 699 | e2 = G(models.Event, context={'key': [test_m2.id, test_m3.id]}, source=s1) 700 | e3 = G(models.Event, context={'key2': test_fk_m1.id, 'key': test_m1.id}, source=s2) 701 | e4 = G(models.Event, context={'key2': test_fk_m2.id}, source=s2) 702 | 703 | context_loader.load_contexts_and_renderers([e1, e2, e3, e4], [medium1, medium2]) 704 | self.assertEqual(e1.context, {'key': test_m1, 'key2': 'haha'}) 705 | self.assertEqual(e2.context, {'key': [test_m2, test_m3]}) 706 | self.assertEqual(e3.context, {'key2': test_fk_m1, 'key': test_m1}) 707 | self.assertEqual(e4.context, {'key2': test_fk_m2}) 708 | 709 | # Verify context renderers are put into the events properly 710 | self.assertEqual(e1._context_renderers, { 711 | medium1: cr1, 712 | medium2: cr1, 713 | }) 714 | self.assertEqual(e2._context_renderers, { 715 | medium1: cr1, 716 | medium2: cr1, 717 | }) 718 | self.assertEqual(e3._context_renderers, { 719 | medium1: cr2, 720 | medium2: cr2, 721 | }) 722 | self.assertEqual(e4._context_renderers, { 723 | medium1: cr2, 724 | medium2: cr2, 725 | }) 726 | 727 | def test_optimal_queries(self): 728 | fk1 = G(test_models.TestFKModel) 729 | fk11 = G(test_models.TestFKModel) 730 | fk2 = G(test_models.TestFKModel2) 731 | test_m1 = G(test_models.TestModel, fk=fk1, fk2=fk2) 732 | test_m1.fk_m2m.add(fk1, fk11) 733 | test_m2 = G(test_models.TestModel, fk=fk1, fk2=fk2) 734 | test_m2.fk_m2m.add(fk1, fk11) 735 | test_m3 = G(test_models.TestModel, fk=fk1, fk2=fk2) 736 | test_fk_m1 = G(test_models.TestFKModel) 737 | test_fk_m2 = G(test_models.TestFKModel) 738 | s1 = G(models.Source) 739 | s2 = G(models.Source) 740 | rg1 = G(models.RenderingStyle) 741 | rg2 = G(models.RenderingStyle) 742 | medium1 = G(models.Medium, rendering_style=rg1) 743 | medium2 = G(models.Medium, rendering_style=rg2) 744 | 745 | G(models.ContextRenderer, rendering_style=rg1, source=s1, context_hints={ 746 | 'key': { 747 | 'model_name': 'TestModel', 748 | 'app_name': 'tests', 749 | 'select_related': ['fk'], 750 | } 751 | }) 752 | G(models.ContextRenderer, rendering_style=rg2, source=s2, context_hints={ 753 | 'key': { 754 | 'model_name': 'TestModel', 755 | 'app_name': 'tests', 756 | 'select_related': ['fk2'], 757 | 'prefetch_related': ['fk_m2m'], 758 | }, 759 | 'key2': { 760 | 'model_name': 'TestFKModel', 761 | 'app_name': 'tests', 762 | } 763 | }) 764 | 765 | e1 = G(models.Event, context={'key': test_m1.id, 'key2': 'haha'}, source=s1) 766 | e2 = G(models.Event, context={'key': [test_m2.id, test_m3.id]}, source=s1) 767 | e3 = G(models.Event, context={'key2': test_fk_m1.id, 'key': test_m1.id}, source=s2) 768 | e4 = G(models.Event, context={'key2': test_fk_m2.id}, source=s2) 769 | 770 | # It appears that django >= 3.2 only needs 4 queries because it ignores an "Id in (NULL)" query for the source 771 | # group 772 | # SELECT "entity_event_sourcegroup".* FROM "entity_event_sourcegroup" 773 | # WHERE "entity_event_sourcegroup"."id" IN (NULL) 774 | num_queries = 5 775 | if (VERSION[0] == 3 and VERSION[1] >= 2) or VERSION[0] >= 4: # pragma: no cover 776 | num_queries = 4 777 | 778 | with self.assertNumQueries(num_queries): 779 | context_loader.load_contexts_and_renderers([e1, e2, e3, e4], [medium1, medium2]) 780 | self.assertEqual(e1.context['key'].fk, fk1) 781 | self.assertEqual(e2.context['key'][0].fk, fk1) 782 | self.assertEqual(e1.context['key'].fk2, fk2) 783 | self.assertEqual(e2.context['key'][0].fk2, fk2) 784 | self.assertEqual(set(e1.context['key'].fk_m2m.all()), set([fk1, fk11])) 785 | self.assertEqual(set(e2.context['key'][0].fk_m2m.all()), set([fk1, fk11])) 786 | self.assertEqual(e3.context['key'].fk, fk1) 787 | -------------------------------------------------------------------------------- /entity_event/tests/model_tests.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from django.template import Template 4 | from django.test import TestCase 5 | from django_dynamic_fixture import N, G 6 | from entity.models import Entity, EntityKind, EntityRelationship 7 | from freezegun import freeze_time 8 | from unittest.mock import patch, call, Mock 9 | from six import text_type 10 | 11 | from entity_event.models import ( 12 | Medium, Source, SourceGroup, Unsubscription, Subscription, Event, EventActor, EventSeen, 13 | RenderingStyle, ContextRenderer, _unseen_event_ids, SubscriptionQuerySet, 14 | EventQuerySet, EventManager 15 | ) 16 | from entity_event.tests.models import TestFKModel 17 | 18 | 19 | class EventRenderTest(TestCase): 20 | """ 21 | Does an entire integration test for rendering events relative to mediums. 22 | """ 23 | def test_one_context_renderer_one_medium_w_additional_context(self): 24 | rg = G(RenderingStyle) 25 | s = G(Source) 26 | G( 27 | ContextRenderer, source=s, rendering_style=rg, text_template_path='test_template.txt', 28 | html_template_path='test_template.html', context_hints={ 29 | 'fk_model': { 30 | 'app_name': 'tests', 31 | 'model_name': 'TestFKModel', 32 | } 33 | }) 34 | m = G(Medium, rendering_style=rg, additional_context={'suppress_value': True}) 35 | 36 | fkm = G(TestFKModel, value=100) 37 | G(Event, source=s, context={'fk_model': fkm.id}) 38 | 39 | events = Event.objects.all().load_contexts_and_renderers(m) 40 | txt, html = events[0].render(m) 41 | 42 | self.assertEqual(txt, 'Test text template with value 100') 43 | self.assertEqual(html, 'Test html template with value suppressed') 44 | 45 | def test_one_context_renderer_one_medium(self): 46 | rg = G(RenderingStyle) 47 | s = G(Source) 48 | G( 49 | ContextRenderer, source=s, rendering_style=rg, text_template_path='test_template.txt', 50 | html_template_path='test_template.html', context_hints={ 51 | 'fk_model': { 52 | 'app_name': 'tests', 53 | 'model_name': 'TestFKModel', 54 | } 55 | }) 56 | m = G(Medium, rendering_style=rg) 57 | 58 | fkm = G(TestFKModel, value=100) 59 | G(Event, source=s, context={'fk_model': fkm.id}) 60 | 61 | events = Event.objects.all().load_contexts_and_renderers(m) 62 | txt, html = events[0].render(m) 63 | 64 | self.assertEqual(txt, 'Test text template with value 100') 65 | self.assertEqual(html, 'Test html template with value 100') 66 | 67 | def test_wo_fetching_contexts(self): 68 | rg = G(RenderingStyle) 69 | s = G(Source) 70 | G( 71 | ContextRenderer, source=s, rendering_style=rg, text_template_path='test_template.txt', 72 | html_template_path='test_template.html', context_hints={ 73 | 'fk_model': { 74 | 'app_name': 'tests', 75 | 'model_name': 'TestFKModel', 76 | } 77 | }) 78 | m = G(Medium, rendering_style=rg) 79 | 80 | fkm = G(TestFKModel, value=100) 81 | e = G(Event, source=s, context={'fk_model': fkm.id}) 82 | 83 | with self.assertRaises(RuntimeError): 84 | e.render(m) 85 | 86 | def test_get_serialized_context(self): 87 | rg = G(RenderingStyle) 88 | s = G(Source) 89 | G( 90 | ContextRenderer, source=s, rendering_style=rg, text_template_path='test_template.txt', 91 | html_template_path='test_template.html', context_hints={ 92 | 'fk_model': { 93 | 'app_name': 'tests', 94 | 'model_name': 'TestFKModel', 95 | } 96 | }) 97 | m = G(Medium, rendering_style=rg, additional_context={'suppress_value': True}) 98 | 99 | fkm = G(TestFKModel, value='100') 100 | G(Event, source=s, context={'fk_model': fkm.id}) 101 | event = Event.objects.all().load_contexts_and_renderers(m)[0] 102 | 103 | # Call the method 104 | response = event.get_serialized_context(m) 105 | 106 | # Assert we have a proper response 107 | self.assertEqual( 108 | response, 109 | { 110 | 'suppress_value': True, 111 | 'fk_model': { 112 | 'id': fkm.id, 113 | 'value': fkm.value 114 | } 115 | } 116 | ) 117 | 118 | def test_get_serialized_context_wo_fetching_context(self): 119 | rg = G(RenderingStyle) 120 | s = G(Source) 121 | G( 122 | ContextRenderer, source=s, rendering_style=rg, text_template_path='test_template.txt', 123 | html_template_path='test_template.html', context_hints={ 124 | 'fk_model': { 125 | 'app_name': 'tests', 126 | 'model_name': 'TestFKModel', 127 | } 128 | }) 129 | m = G(Medium, rendering_style=rg, additional_context={'suppress_value': True}) 130 | 131 | fkm = G(TestFKModel, value='100') 132 | event = G(Event, source=s, context={'fk_model': fkm.id}) 133 | 134 | with self.assertRaises(RuntimeError): 135 | event.get_serialized_context(m) 136 | 137 | 138 | class EventManagerCreateEventTest(TestCase): 139 | def test_create_event_no_actors(self): 140 | source = G(Source) 141 | e = Event.objects.create_event(context={'hi': 'hi'}, source=source) 142 | self.assertEqual(e.source, source) 143 | self.assertEqual(e.context, {'hi': 'hi'}) 144 | self.assertEqual(e.uuid, '') 145 | self.assertFalse(EventActor.objects.exists()) 146 | 147 | def test_create_event_multiple_actor_pks(self): 148 | source = G(Source) 149 | actors = [G(Entity), G(Entity)] 150 | e = Event.objects.create_event(context={'hi': 'hi'}, source=source, actors=[a.id for a in actors], uuid='hi') 151 | self.assertEqual(e.source, source) 152 | self.assertEqual(e.context, {'hi': 'hi'}) 153 | self.assertEqual(e.uuid, 'hi') 154 | self.assertEqual( 155 | set(EventActor.objects.filter(event=e).values_list('entity', flat=True)), set([a.id for a in actors])) 156 | 157 | def test_create_event_multiple_actors(self): 158 | source = G(Source) 159 | actors = [G(Entity), G(Entity)] 160 | e = Event.objects.create_event(context={'hi': 'hi'}, source=source, actors=actors, uuid='hi') 161 | self.assertEqual(e.source, source) 162 | self.assertEqual(e.context, {'hi': 'hi'}) 163 | self.assertEqual(e.uuid, 'hi') 164 | self.assertEqual( 165 | set(EventActor.objects.filter(event=e).values_list('entity', flat=True)), set([a.id for a in actors])) 166 | 167 | def test_ignore_duplicates_w_uuid_doesnt_already_exist(self): 168 | source = G(Source) 169 | e = Event.objects.create_event(context={'hi': 'hi'}, source=source, uuid='1', ignore_duplicates=True) 170 | self.assertIsNotNone(e) 171 | 172 | def test_ignore_duplicates_w_uuid_already_exist(self): 173 | source = G(Source) 174 | Event.objects.create_event(context={'hi': 'hi'}, source=source, uuid='1', ignore_duplicates=True) 175 | e = Event.objects.create_event(context={'hi': 'hi'}, source=source, uuid='1', ignore_duplicates=True) 176 | self.assertIsNone(e) 177 | 178 | def test_ignore_duplicates_wo_uuid_already_exist(self): 179 | source = G(Source) 180 | Event.objects.create_event(context={'hi': 'hi'}, source=source, ignore_duplicates=True) 181 | e = Event.objects.create_event(context={'hi': 'hi'}, source=source, ignore_duplicates=True) 182 | self.assertIsNone(e) 183 | 184 | def test_create_events(self): 185 | """ 186 | Tests the bulk event creation to make sure all data gets set correctly 187 | """ 188 | source = G(Source) 189 | Event.objects.create_event(context={'hi': 'hi'}, source=source, ignore_duplicates=True) 190 | actor1 = G(Entity) 191 | actor2 = G(Entity) 192 | actor3 = G(Entity) 193 | actor4 = G(Entity) 194 | 195 | event_kwargs = [{ 196 | 'context': {'one': 'one'}, 197 | 'source': source, 198 | 'ignore_duplicates': True, 199 | 'actors': [actor1, actor2], 200 | 'uuid': '1' 201 | }, { 202 | 'context': {'two': 'two'}, 203 | 'source': source, 204 | 'ignore_duplicates': True, 205 | 'actors': [actor2], 206 | 'uuid': '2' 207 | }] 208 | events = Event.objects.create_events(event_kwargs) 209 | events.sort(key=lambda x: x.uuid) 210 | 211 | self.assertEqual(len(events), 2) 212 | 213 | self.assertEqual(events[0].uuid, '1') 214 | self.assertEqual(events[0].context['one'], 'one') 215 | self.assertEqual(events[0].source, source) 216 | self.assertEqual( 217 | {event_actor.entity_id for event_actor in events[0].eventactor_set.all()}, 218 | set([actor1.id, actor2.id]) 219 | ) 220 | 221 | self.assertEqual(events[1].uuid, '2') 222 | self.assertEqual(events[1].context['two'], 'two') 223 | self.assertEqual(events[1].source, source) 224 | self.assertEqual( 225 | {event_actor.entity_id for event_actor in events[1].eventactor_set.all()}, 226 | set([actor2.id]) 227 | ) 228 | 229 | # Add some events where one is a duplicate 230 | event_kwargs = [{ 231 | 'context': {'one': 'one'}, 232 | 'source': source, 233 | 'ignore_duplicates': True, 234 | 'actors': [actor3, actor4], 235 | 'uuid': '1' 236 | }, { 237 | 'context': {'three': 'three'}, 238 | 'source': source, 239 | 'ignore_duplicates': True, 240 | 'actors': [actor3], 241 | 'uuid': '3' 242 | }] 243 | events = Event.objects.create_events(event_kwargs) 244 | self.assertEqual(len(events), 1) 245 | 246 | self.assertEqual(events[0].uuid, '3') 247 | self.assertEqual(events[0].context['three'], 'three') 248 | self.assertEqual(events[0].source, source) 249 | self.assertEqual( 250 | {event_actor.entity_id for event_actor in events[0].eventactor_set.all()}, 251 | set([actor3.id, actor3.id]) 252 | ) 253 | 254 | # All duplicates 255 | event_kwargs = [{ 256 | 'context': {'one': 'one'}, 257 | 'source': source, 258 | 'ignore_duplicates': True, 259 | 'actors': [actor3, actor4], 260 | 'uuid': '1' 261 | }, { 262 | 'context': {'three': 'three'}, 263 | 'source': source, 264 | 'ignore_duplicates': True, 265 | 'actors': [actor3], 266 | 'uuid': '3' 267 | }] 268 | events = Event.objects.create_events(event_kwargs) 269 | self.assertEqual(len(events), 0) 270 | 271 | 272 | class EventManagerQuerySetTest(TestCase): 273 | def setUp(self): 274 | # Call the super setup 275 | super(EventManagerQuerySetTest, self).setUp() 276 | 277 | # Create a query set reference 278 | self.queryset = EventQuerySet() 279 | 280 | # Create a manager reference 281 | self.manager = EventManager() 282 | 283 | def test_mark_seen(self): 284 | event = G(Event, context={}) 285 | medium = G(Medium) 286 | Event.objects.mark_seen(medium) 287 | self.assertEqual(EventSeen.objects.count(), 1) 288 | self.assertTrue(EventSeen.objects.filter(event=event, medium=medium).exists()) 289 | 290 | @patch('entity_event.context_loader.load_contexts_and_renderers', spec_set=True) 291 | def test_load_contexts_and_renderers(self, mock_load_contexts_and_renderers): 292 | e = G(Event, context={}) 293 | medium = G(Medium) 294 | Event.objects.load_contexts_and_renderers(medium) 295 | self.assertEqual(mock_load_contexts_and_renderers.call_count, 1) 296 | self.assertEqual(list(mock_load_contexts_and_renderers.call_args_list[0][0][0]), [e]) 297 | self.assertEqual(mock_load_contexts_and_renderers.call_args_list[0][0][1], [medium]) 298 | 299 | @patch.object(EventManager, 'get_queryset', autospec=True) 300 | def test_cache_related(self, mock_get_queryset): 301 | # Setup some mock return values 302 | mock_get_queryset.return_value = Mock(EventQuerySet(), autospec=True) 303 | 304 | # Call the method 305 | self.manager.cache_related() 306 | 307 | # Assert that we called get queryset 308 | mock_get_queryset.assert_called_once_with(self.manager) 309 | 310 | 311 | class MediumEventsInterfacesTest(TestCase): 312 | def setUp(self): 313 | # Call the parent setup 314 | super(MediumEventsInterfacesTest, self).setUp() 315 | 316 | # Set Up Entities and Relationships 317 | everyone_kind = G(EntityKind, name='all', display_name='all') 318 | group_kind = G(EntityKind, name='group', display_name='Group') 319 | self.person_kind = G(EntityKind, name='person', display_name='Person') 320 | 321 | # Setup people entities 322 | self.p1 = G(Entity, entity_kind=self.person_kind, display_name='p1') 323 | self.p2 = G(Entity, entity_kind=self.person_kind, display_name='p2') 324 | self.p3 = G(Entity, entity_kind=self.person_kind, display_name='p3') 325 | p4 = G(Entity, entity_kind=self.person_kind, display_name='p4') 326 | 327 | # Setup group entities 328 | g1 = G(Entity, entity_kind=group_kind) 329 | g2 = G(Entity, entity_kind=group_kind) 330 | 331 | # Setup the global entity 332 | everyone = G(Entity, entity_kind=everyone_kind) 333 | 334 | # Assign entity relationships 335 | # p1 and p2 are in group1, p3 and p4 are in group2 336 | for sup, sub in [(g1, self.p1), (g1, self.p2), (g2, self.p3), (g2, p4)]: 337 | G(EntityRelationship, super_entity=sup, sub_entity=sub) 338 | 339 | # All people are in the everyone group 340 | for p in [self.p1, self.p2, self.p3, p4]: 341 | G(EntityRelationship, super_entity=everyone, sub_entity=p) 342 | 343 | # Set up Mediums, Sources, Subscriptions, Events 344 | # We are creating 4 events 345 | # 2 for source a, 1 has p1 as an actor, the other has no actors 346 | # 1 for source b, for p2 347 | # 1 for source c, for p2 and p3 348 | self.medium_x = G(Medium, name='x', display_name='x') 349 | self.medium_y = G(Medium, name='y', display_name='y') 350 | self.medium_z = G(Medium, name='z', display_name='z') 351 | self.source_a = G(Source, name='a', display_name='a') 352 | self.source_b = G(Source, name='b', display_name='b') 353 | self.source_c = G(Source, name='c', display_name='c') 354 | 355 | e1 = G(Event, source=self.source_a, context={}) 356 | G(Event, source=self.source_a, context={}) 357 | e3 = G(Event, source=self.source_b, context={}) 358 | e4 = G(Event, source=self.source_c, context={}) 359 | 360 | G(EventActor, event=e1, entity=self.p1) 361 | G(EventActor, event=e3, entity=self.p2) 362 | G(EventActor, event=e4, entity=self.p2) 363 | G(EventActor, event=e4, entity=self.p3) 364 | 365 | # Create subscriptions 366 | # Source a is subscribed to medium x, for everyone of person not following 367 | # source a is subscribed to medium y, for everyone of person, following 368 | # source c is subscribed to medium z, for group1, following 369 | G( 370 | Subscription, 371 | source=self.source_a, 372 | medium=self.medium_x, 373 | only_following=False, 374 | entity=everyone, 375 | sub_entity_kind=self.person_kind 376 | ) 377 | G( 378 | Subscription, 379 | source=self.source_a, 380 | medium=self.medium_y, 381 | only_following=True, 382 | entity=everyone, 383 | sub_entity_kind=self.person_kind 384 | ) 385 | G( 386 | Subscription, 387 | source=self.source_c, 388 | medium=self.medium_z, 389 | only_following=True, 390 | entity=g1, 391 | sub_entity_kind=self.person_kind 392 | ) 393 | 394 | def test_events_basic(self): 395 | events = self.medium_x.events() 396 | self.assertEqual(events.count(), 2) 397 | 398 | def test_events_only_following(self): 399 | events = self.medium_y.events() 400 | self.assertEqual(events.count(), 1) 401 | 402 | def test_entity_events_basic(self): 403 | events = self.medium_x.entity_events(entity=self.p1) 404 | self.assertEqual(len(events), 2) 405 | 406 | def test_entity_events_basic_mark_seen(self): 407 | events = self.medium_x.entity_events( 408 | entity=self.p1, 409 | seen=False, 410 | mark_seen=True 411 | ) 412 | self.assertEqual(len(events), 2) 413 | 414 | # The other medium should also get marked as seen 415 | self.assertEqual(len(EventSeen.objects.all()), 4) 416 | 417 | def test_entity_events_basic_unsubscribed(self): 418 | G(Unsubscription, entity=self.p1, source=self.source_a, medium=self.medium_x) 419 | G(Event, source=self.source_b, context={}) 420 | G(Subscription, source=self.source_b, medium=self.medium_x, only_following=False, 421 | entity=self.p1, sub_entity_kind=None) 422 | events = self.medium_x.entity_events(entity=self.p1) 423 | self.assertEqual(len(events), 2) 424 | for event in events: 425 | self.assertEqual(event.source, self.source_b) 426 | 427 | def test_entity_events_only_following(self): 428 | events = self.medium_z.entity_events(entity=self.p2) 429 | self.assertEqual(len(events), 1) 430 | 431 | def test_entity_targets_basic(self): 432 | events_targets = self.medium_x.events_targets() 433 | self.assertEqual(len(events_targets), 2) 434 | 435 | def test_entity_targets_target_count(self): 436 | events_targets = self.medium_x.events_targets(entity_kind=self.person_kind) 437 | self.assertEqual(len(events_targets[0][1]), 4) 438 | 439 | def test_entity_targets_only_following(self): 440 | events_targets = self.medium_z.events_targets(entity_kind=self.person_kind) 441 | self.assertEqual(len(events_targets[0][1]), 1) 442 | 443 | 444 | class MediumTest(TestCase): 445 | 446 | def test_events_targets_start_time(self): 447 | """ 448 | Makes sure that only events created after the medium are returned 449 | """ 450 | entity = G(Entity) 451 | 452 | source1 = G(Source) 453 | source2 = G(Source) 454 | 455 | # Make some events before the mediums 456 | G(Event, uuid='1', source=source1, context={}) 457 | G(Event, uuid='2', source=source1, context={}) 458 | G(Event, uuid='3', source=source2, context={}) 459 | G(Event, uuid='4', source=source2, context={}) 460 | 461 | medium1 = G(Medium) 462 | medium2 = G(Medium) 463 | 464 | # Make events after the mediums 465 | G(Event, uuid='5', source=source1, context={}) 466 | G(Event, uuid='6', source=source2, context={}) 467 | 468 | # Make subscriptions to different sources and mediums 469 | G(Subscription, medium=medium1, source=source1, entity=entity, only_following=False) 470 | G(Subscription, medium=medium2, source=source1, entity=entity, only_following=False) 471 | 472 | # Get all events for medium 1 473 | events = [] 474 | for event, targets in medium1.events_targets(start_time=medium1.time_created): 475 | events.append(event) 476 | 477 | # There should only be 1 event for medium 1 after the mediums were made 478 | self.assertEqual(len(events), 1) 479 | 480 | 481 | class MediumRenderTest(TestCase): 482 | @patch('entity_event.context_loader.load_contexts_and_renderers', spec_set=True) 483 | def test_render(self, mock_load_contexts_and_renderers): 484 | medium = N(Medium) 485 | e1 = Mock(render=Mock(return_value=('e1.txt', 'e1.html'))) 486 | e2 = Mock(render=Mock(return_value=('e2.txt', 'e2.html'))) 487 | 488 | events = [e1, e2] 489 | res = medium.render(events) 490 | 491 | mock_load_contexts_and_renderers.assert_called_once_with(events, [medium]) 492 | self.assertEqual(res, { 493 | e1: ('e1.txt', 'e1.html'), 494 | e2: ('e2.txt', 'e2.html'), 495 | }) 496 | e1.render.assert_called_once_with(medium) 497 | e2.render.assert_called_once_with(medium) 498 | 499 | 500 | class MediumSubsetSubscriptionsTest(TestCase): 501 | def setUp(self): 502 | person = G(EntityKind, name='person', display_name='Person') 503 | self.super_e = G(Entity) 504 | self.sub_e = G(Entity, entity_kind=person) 505 | random = G(Entity) 506 | G(EntityRelationship, super_entity=self.super_e, sub_entity=self.sub_e) 507 | 508 | self.medium = G(Medium) 509 | self.group_sub = G(Subscription, entity=self.super_e, sub_entity_kind=person) 510 | self.indiv_sub = G(Subscription, entity=self.sub_e, sub_entity_kind=None) 511 | self.random_sub = G(Subscription, entity=random) 512 | 513 | def test_no_entity(self): 514 | all_subs = Subscription.objects.all() 515 | subs = self.medium.subset_subscriptions(all_subs) 516 | self.assertEqual(subs, all_subs) 517 | 518 | def test_sub_entity(self): 519 | all_subs = Subscription.objects.all() 520 | subs = self.medium.subset_subscriptions(all_subs, self.sub_e) 521 | self.assertEqual(subs.count(), 2) 522 | 523 | def test_super_not_included(self): 524 | all_subs = Subscription.objects.all() 525 | subs = self.medium.subset_subscriptions(all_subs, self.super_e) 526 | self.assertEqual(subs.count(), 0) 527 | 528 | 529 | class MediumGetFilteredEventsTest(TestCase): 530 | def setUp(self): 531 | super(MediumGetFilteredEventsTest, self).setUp() 532 | 533 | self.entity = G(Entity) 534 | self.medium = G(Medium) 535 | self.source = G(Source) 536 | G(Subscription, medium=self.medium, source=self.source, entity=self.entity, only_following=False) 537 | 538 | def test_get_unseen_events_some_seen_some_not(self): 539 | seen_e = G(Event, context={}, source=self.source) 540 | G(EventSeen, event=seen_e, medium=self.medium) 541 | unseen_e = G(Event, context={}, source=self.source) 542 | 543 | events = self.medium.get_filtered_events(seen=False) 544 | self.assertEqual(list(events), [unseen_e]) 545 | 546 | def test_get_unseen_events_some_seen_from_other_mediums(self): 547 | seen_from_other_medium_e = G(Event, context={}) 548 | seen_from_medium_event = G(Event, context={}, source=self.source) 549 | unseen_e = G(Event, context={}, source=self.source) 550 | G(EventSeen, event=seen_from_other_medium_e) 551 | G(EventSeen, event=seen_from_medium_event) 552 | 553 | events = self.medium.get_filtered_events(seen=False) 554 | self.assertEqual(set(events), {unseen_e, seen_from_medium_event, seen_from_other_medium_e}) 555 | 556 | 557 | class MediumGetEventFiltersTest(TestCase): 558 | def setUp(self): 559 | # Call the parent setup 560 | super(MediumGetEventFiltersTest, self).setUp() 561 | 562 | # Create some entities 563 | self.entity = G(Entity) 564 | self.entity2 = G(Entity) 565 | 566 | # Create some sources 567 | self.source = G(Source) 568 | self.source2 = G(Source) 569 | 570 | # Make a couple mediums 571 | self.medium = G(Medium) 572 | self.medium2 = G(Medium) 573 | 574 | # Make subscriptions to different sources and mediums 575 | G(Subscription, medium=self.medium, source=self.source, entity=self.entity, only_following=False) 576 | G(Subscription, medium=self.medium2, source=self.source2, entity=self.entity2, only_following=False) 577 | 578 | with freeze_time('2014-01-15'): 579 | self.event1 = G(Event, context={}, source=self.source) 580 | self.event2 = G(Event, context={}, source=self.source, time_expires=datetime(5000, 1, 1)) 581 | with freeze_time('2014-01-17'): 582 | self.event3 = G(Event, context={}, source=self.source) 583 | self.event4 = G(Event, context={}, source=self.source2) 584 | self.event5 = G(Event, context={}, source=self.source2, time_expires=datetime(2014, 1, 17)) 585 | 586 | # Mark one event as seen by the medium 587 | G(EventSeen, event=self.event1, medium=self.medium) 588 | 589 | self.actor = G(Entity) 590 | G(EventActor, event=self.event1, entity=self.actor) 591 | 592 | def test_start_time(self): 593 | events = self.medium.get_filtered_events_queryset( 594 | start_time=datetime(2014, 1, 16), 595 | end_time=None, 596 | seen=None, 597 | include_expired=True, 598 | actor=None 599 | ) 600 | self.assertEqual(events.count(), 3) 601 | 602 | events = self.medium2.get_filtered_events_queryset( 603 | start_time=datetime(2014, 1, 16), 604 | end_time=None, 605 | seen=None, 606 | include_expired=True, 607 | actor=None 608 | ) 609 | self.assertEqual(events.count(), 3) 610 | 611 | def test_end_time(self): 612 | events = self.medium.get_filtered_events_queryset( 613 | start_time=None, 614 | end_time=datetime(2014, 1, 16), 615 | seen=None, 616 | include_expired=True, 617 | actor=None 618 | ) 619 | self.assertEqual(events.count(), 2) 620 | 621 | events = self.medium2.get_filtered_events_queryset( 622 | start_time=None, 623 | end_time=datetime(2014, 1, 16), 624 | seen=None, 625 | include_expired=True, 626 | actor=None 627 | ) 628 | self.assertEqual(events.count(), 2) 629 | 630 | def test_is_seen(self): 631 | events = self.medium.get_filtered_events_queryset( 632 | start_time=None, 633 | end_time=None, 634 | seen=True, 635 | include_expired=True, 636 | actor=None 637 | ) 638 | self.assertEqual(events.count(), 1) 639 | 640 | events = self.medium2.get_filtered_events_queryset( 641 | start_time=None, 642 | end_time=None, 643 | seen=True, 644 | include_expired=True, 645 | actor=None 646 | ) 647 | self.assertEqual(events.count(), 0) 648 | 649 | def test_is_not_seen(self): 650 | events = self.medium.get_filtered_events_queryset( 651 | start_time=None, 652 | end_time=None, 653 | seen=False, 654 | include_expired=True, 655 | actor=None 656 | ) 657 | self.assertEqual(events.count(), 4) 658 | 659 | # Make sure these are the events we expect 660 | event_ids = {event.id for event in events} 661 | expected_ids = {self.event2.id, self.event3.id, self.event4.id, self.event5.id} 662 | self.assertEqual(event_ids, expected_ids) 663 | 664 | # Mark these all as seen 665 | Event.objects.filter(id__in=expected_ids).mark_seen(self.medium) 666 | 667 | # Make sure there are no unseen for medium1 668 | events = self.medium.get_filtered_events_queryset( 669 | start_time=None, 670 | end_time=None, 671 | seen=False, 672 | include_expired=True, 673 | actor=None 674 | ) 675 | self.assertEqual(events.count(), 0) 676 | 677 | # Make sure there we still have unseen for medium 2 678 | events = self.medium2.get_filtered_events_queryset( 679 | start_time=None, 680 | end_time=None, 681 | seen=False, 682 | include_expired=True, 683 | actor=None 684 | ) 685 | self.assertEqual(events.count(), 5) 686 | 687 | # Delete one of the events from seen 688 | EventSeen.objects.filter(medium=self.medium, event=self.event3).delete() 689 | 690 | # Make sure there is one unseen 691 | events = self.medium.get_filtered_events_queryset( 692 | start_time=None, 693 | end_time=None, 694 | seen=False, 695 | include_expired=True, 696 | actor=None 697 | ) 698 | self.assertEqual(events.count(), 1) 699 | self.assertEqual(events[0].id, self.event3.id) 700 | 701 | # Mark these all as seen 702 | Event.objects.filter(id=self.event3.id).mark_seen(self.medium) 703 | 704 | # Make sure there are no unseen 705 | events = self.medium.get_filtered_events_queryset( 706 | start_time=None, 707 | end_time=None, 708 | seen=False, 709 | include_expired=True, 710 | actor=None 711 | ) 712 | self.assertEqual(events.count(), 0) 713 | 714 | # Make a new event 715 | self.event6 = G(Event, context={}, source=self.source) 716 | 717 | # Make sure the new event shows up 718 | # Make sure there is one unseen 719 | events = self.medium.get_filtered_events_queryset( 720 | start_time=None, 721 | end_time=None, 722 | seen=False, 723 | include_expired=True, 724 | actor=None 725 | ) 726 | self.assertEqual(events.count(), 1) 727 | self.assertEqual(events[0].id, self.event6.id) 728 | 729 | # Check the other medium 730 | events = self.medium2.get_filtered_events_queryset( 731 | start_time=None, 732 | end_time=None, 733 | seen=False, 734 | include_expired=True, 735 | actor=None 736 | ) 737 | self.assertEqual(events.count(), 6) 738 | 739 | def test_include_expires(self): 740 | events = self.medium.get_filtered_events_queryset( 741 | start_time=None, 742 | end_time=None, 743 | seen=None, 744 | include_expired=True, 745 | actor=None 746 | ) 747 | self.assertEqual(events.count(), 5) 748 | 749 | events = self.medium2.get_filtered_events_queryset( 750 | start_time=None, 751 | end_time=None, 752 | seen=None, 753 | include_expired=True, 754 | actor=None 755 | ) 756 | self.assertEqual(events.count(), 5) 757 | 758 | def test_dont_include_expires(self): 759 | events = self.medium.get_filtered_events_queryset( 760 | start_time=None, 761 | end_time=None, 762 | seen=None, 763 | include_expired=False, 764 | actor=None 765 | ) 766 | self.assertEqual(events.count(), 4) 767 | 768 | events = self.medium2.get_filtered_events_queryset( 769 | start_time=None, 770 | end_time=None, 771 | seen=None, 772 | include_expired=False, 773 | actor=None 774 | ) 775 | self.assertEqual(events.count(), 4) 776 | 777 | def test_actor(self): 778 | events = self.medium.get_filtered_events_queryset( 779 | start_time=None, 780 | end_time=None, 781 | seen=None, 782 | include_expired=True, 783 | actor=self.actor 784 | ) 785 | self.assertEqual(events.count(), 1) 786 | 787 | 788 | class MediumFollowedByTest(TestCase): 789 | def setUp(self): 790 | self.medium = N(Medium) 791 | self.superentity = G(Entity) 792 | self.sub1, self.sub2 = G(Entity), G(Entity) 793 | G(EntityRelationship, super_entity=self.superentity, sub_entity=self.sub1) 794 | G(EntityRelationship, super_entity=self.superentity, sub_entity=self.sub2) 795 | 796 | def test_self_in(self): 797 | followers = self.medium.followed_by(self.sub1) 798 | super_entity_in = followers.filter(id=self.sub1.id).exists() 799 | self.assertTrue(super_entity_in) 800 | 801 | def test_super_entities_in(self): 802 | followers = self.medium.followed_by(self.sub1) 803 | sub_entity_in = followers.filter(id=self.superentity.id).exists() 804 | self.assertTrue(sub_entity_in) 805 | 806 | def test_others_not_in(self): 807 | followers = self.medium.followed_by(self.sub1) 808 | random_entity_in = followers.filter(id=self.sub2.id).exists() 809 | self.assertFalse(random_entity_in) 810 | 811 | def test_multiple_inputs_list(self): 812 | followers = self.medium.followed_by([self.sub1.id, self.sub2.id]) 813 | self.assertEqual(followers.count(), 3) 814 | 815 | def test_multiple_inputs_qs(self): 816 | entities = Entity.objects.filter(id__in=[self.sub1.id, self.sub2.id]) 817 | followers = self.medium.followed_by(entities) 818 | self.assertEqual(followers.count(), 3) 819 | 820 | 821 | class MediumFollowersOfTest(TestCase): 822 | def setUp(self): 823 | self.medium = N(Medium) 824 | self.superentity = G(Entity) 825 | self.sub1, self.sub2 = G(Entity), G(Entity) 826 | self.random_entity = G(Entity) 827 | G(EntityRelationship, super_entity=self.superentity, sub_entity=self.sub1) 828 | G(EntityRelationship, super_entity=self.superentity, sub_entity=self.sub2) 829 | 830 | def test_self_in(self): 831 | followers = self.medium.followers_of(self.superentity) 832 | super_entity_in = followers.filter(id=self.superentity.id).exists() 833 | self.assertTrue(super_entity_in) 834 | 835 | def test_sub_entities_in(self): 836 | followers = self.medium.followers_of(self.superentity) 837 | sub_entity_in = followers.filter(id=self.sub1.id).exists() 838 | self.assertTrue(sub_entity_in) 839 | 840 | def test_others_not_in(self): 841 | followers = self.medium.followers_of(self.superentity) 842 | random_entity_in = followers.filter(id=self.random_entity.id).exists() 843 | self.assertFalse(random_entity_in) 844 | 845 | def test_multiple_inputs_list(self): 846 | followers = self.medium.followers_of([self.sub1.id, self.sub2.id]) 847 | self.assertEqual(followers.count(), 2) 848 | 849 | def test_multiple_inputs_qs(self): 850 | entities = Entity.objects.filter(id__in=[self.sub1.id, self.sub2.id]) 851 | followers = self.medium.followers_of(entities) 852 | self.assertEqual(followers.count(), 2) 853 | 854 | 855 | class SubscriptionSubscribedEntitiesTest(TestCase): 856 | def setUp(self): 857 | person_kind = G(EntityKind, name='person', display_name='person') 858 | superentity = G(Entity) 859 | sub1, sub2 = G(Entity, entity_kind=person_kind), G(Entity, entity_kind=person_kind) 860 | G(EntityRelationship, super_entity=superentity, sub_entity=sub1) 861 | G(EntityRelationship, super_entity=superentity, sub_entity=sub2) 862 | 863 | self.group_sub = N(Subscription, entity=superentity, sub_entity_kind=person_kind) 864 | self.indiv_sub = N(Subscription, entity=superentity, sub_entity_kind=None) 865 | 866 | def test_both_branches_return_queryset(self): 867 | group_qs = self.group_sub.subscribed_entities() 868 | indiv_qs = self.indiv_sub.subscribed_entities() 869 | self.assertEqual(type(group_qs), type(indiv_qs)) 870 | 871 | def test_length_group(self): 872 | group_qs = self.group_sub.subscribed_entities() 873 | self.assertEqual(group_qs.count(), 2) 874 | 875 | def test_length_indiv(self): 876 | indiv_qs = self.indiv_sub.subscribed_entities() 877 | self.assertEqual(indiv_qs.count(), 1) 878 | 879 | 880 | class ContextRendererRenderTextOrHtmlTemplateTest(TestCase): 881 | @patch('entity_event.models.render_to_string') 882 | def test_w_html_template_path(self, mock_render_to_string): 883 | cr = N(ContextRenderer, html_template_path='html_path') 884 | c = {'context': 'context'} 885 | cr.render_text_or_html_template(c, is_text=False) 886 | mock_render_to_string.assert_called_once_with('html_path', c) 887 | 888 | @patch('entity_event.models.render_to_string') 889 | def test_w_text_template_path(self, mock_render_to_string): 890 | cr = N(ContextRenderer, text_template_path='text_path') 891 | c = {'context': 'context'} 892 | cr.render_text_or_html_template(c, is_text=True) 893 | mock_render_to_string.assert_called_once_with('text_path', c) 894 | 895 | @patch.object(Template, '__init__', return_value=None) 896 | @patch.object(Template, 'render') 897 | def test_w_html_template(self, mock_render, mock_init): 898 | cr = N(ContextRenderer, html_template='html_template') 899 | c = {'context': 'context'} 900 | cr.render_text_or_html_template(c, is_text=False) 901 | self.assertEqual(mock_render.call_count, 1) 902 | mock_init.assert_called_once_with('html_template') 903 | 904 | @patch.object(Template, '__init__', return_value=None) 905 | @patch.object(Template, 'render') 906 | def test_w_text_template(self, mock_render, mock_init): 907 | cr = N(ContextRenderer, text_template='text_template') 908 | c = {'context': 'context'} 909 | cr.render_text_or_html_template(c, is_text=True) 910 | self.assertEqual(mock_render.call_count, 1) 911 | mock_init.assert_called_once_with('text_template') 912 | 913 | def test_w_no_templates_text(self): 914 | cr = N(ContextRenderer) 915 | c = {'context': 'context'} 916 | self.assertEqual(cr.render_text_or_html_template(c, is_text=True), '') 917 | 918 | def test_w_no_templates_html(self): 919 | cr = N(ContextRenderer) 920 | c = {'context': 'context'} 921 | self.assertEqual(cr.render_text_or_html_template(c, is_text=False), '') 922 | 923 | 924 | class ContextRendererRenderContextToTextHtmlTemplates(TestCase): 925 | @patch.object(ContextRenderer, 'render_text_or_html_template', spec_set=True) 926 | def test_render_context_to_text_html_templates(self, mock_render_text_or_html_template): 927 | c = {'context': 'context'} 928 | r = ContextRenderer().render_context_to_text_html_templates(c) 929 | self.assertEqual( 930 | r, ( 931 | mock_render_text_or_html_template.return_value.strip(), 932 | mock_render_text_or_html_template.return_value.strip() 933 | )) 934 | self.assertEqual( 935 | mock_render_text_or_html_template.call_args_list, [call(c, is_text=True), call(c, is_text=False)]) 936 | 937 | 938 | class ContextRendererGetSerializedContextTests(TestCase): 939 | @patch('entity_event.models.DefaultContextSerializer') 940 | def test_get_serialized_context(self, mock_default_context_serializer): 941 | context = {'context': 'context'} 942 | response = ContextRenderer().get_serialized_context(context) 943 | 944 | # Assert we have a proper response 945 | self.assertEqual(response, mock_default_context_serializer.return_value.data) 946 | 947 | # Assert that we created the default serializer correctly 948 | mock_default_context_serializer.assert_called_once_with(context) 949 | 950 | 951 | class UnseenEventIdsTest(TestCase): 952 | def test_filters_seen(self): 953 | entity = G(Entity) 954 | medium = G(Medium) 955 | source = G(Source) 956 | G(Subscription, entity=entity, source=source, medium=medium) 957 | e1 = G(Event, context={}, source=source) 958 | e2 = G(Event, context={}, source=source) 959 | Event.objects.filter(id=e2.id).mark_seen(medium) 960 | unseen_ids = _unseen_event_ids(medium) 961 | self.assertEqual(set(unseen_ids), {e1.id}) 962 | 963 | def test_multiple_mediums(self): 964 | entity1 = G(Entity) 965 | entity2 = G(Entity) 966 | source1 = G(Source) 967 | source2 = G(Source) 968 | medium1 = G(Medium) 969 | medium2 = G(Medium) 970 | G(Subscription, entity=entity1, source=source1, medium=medium1) 971 | G(Subscription, entity=entity1, source=source1, medium=medium2) 972 | G(Subscription, entity=entity2, source=source2, medium=medium2) 973 | event1 = G(Event, context={}, source=source1) 974 | event2 = G(Event, context={}, source=source1) 975 | event3 = G(Event, context={}, source=source2) 976 | event4 = G(Event, context={}, source=source2) 977 | Event.objects.filter(id=event2.id).mark_seen(medium1) 978 | Event.objects.filter(id=event2.id).mark_seen(medium2) 979 | Event.objects.filter(id=event3.id).mark_seen(medium2) 980 | unseen_ids = _unseen_event_ids(medium1) 981 | self.assertEqual(set(unseen_ids), {event1.id, event3.id, event4.id}) 982 | unseen_ids = _unseen_event_ids(medium2) 983 | self.assertEqual(set(unseen_ids), {event1.id, event4.id}) 984 | 985 | 986 | class UnicodeTest(TestCase): 987 | def setUp(self): 988 | self.rendering_style = N(RenderingStyle, display_name='Test Render Group', name='test') 989 | self.context_renderer = N(ContextRenderer, name='Test Context Renderer') 990 | self.medium = G(Medium, display_name='Test Medium') 991 | self.source = G(Source, display_name='Test Source') 992 | self.source_group = G(SourceGroup, display_name='Test Source Group') 993 | self.entity = G(Entity, display_name='Test Entity') 994 | self.unsubscription = N( 995 | Unsubscription, entity=self.entity, medium=self.medium, source=self.source) 996 | self.subscription = N( 997 | Subscription, entity=self.entity, source=self.source, medium=self.medium) 998 | self.event = N(Event, source=self.source, context={}, id=1) 999 | self.event_actor = N(EventActor, event=self.event, entity=self.entity) 1000 | self.event_seen = N( 1001 | EventSeen, event=self.event, medium=self.medium, time_seen=datetime(2014, 1, 2)) 1002 | 1003 | def test_RenderingStyle_formats(self): 1004 | s = text_type(self.rendering_style) 1005 | self.assertEqual(s, 'Test Render Group test') 1006 | 1007 | def test_contextrenderer_formats(self): 1008 | s = text_type(self.context_renderer) 1009 | self.assertEqual(s, 'Test Context Renderer') 1010 | 1011 | def test_medium_formats(self): 1012 | s = text_type(self.medium) 1013 | self.assertEqual(s, 'Test Medium') 1014 | 1015 | def test_source_formats(self): 1016 | s = text_type(self.source) 1017 | self.assertEqual(s, 'Test Source') 1018 | 1019 | def test_sourcegroup_formats(self): 1020 | s = text_type(self.source_group) 1021 | self.assertEqual(s, 'Test Source Group') 1022 | 1023 | def test_unsubscription_formats(self): 1024 | s = text_type(self.unsubscription) 1025 | self.assertEqual(s, '{0} from Test Source by Test Medium'.format(self.entity)) 1026 | 1027 | def test_subscription_formats(self): 1028 | s = text_type(self.subscription) 1029 | self.assertEqual(s, '{0} to Test Source by Test Medium'.format(self.entity)) 1030 | 1031 | def test_event_formats(self): 1032 | s = text_type(self.event) 1033 | self.assertTrue(s.startswith('Test Source event at')) 1034 | 1035 | def test_eventactor_formats(self): 1036 | s = text_type(self.event_actor) 1037 | self.assertEqual(s, 'Event 1 - {0}'.format(self.entity)) 1038 | 1039 | def test_event_seenformats(self): 1040 | s = text_type(self.event_seen) 1041 | self.assertEqual(s, 'Seen on Test Medium at 2014-01-02::00:00:00') 1042 | 1043 | 1044 | class SubscriptionQuerySetTest(TestCase): 1045 | """ 1046 | Test the subscription query set class 1047 | """ 1048 | 1049 | def setUp(self): 1050 | # Call super 1051 | super(SubscriptionQuerySetTest, self).setUp() 1052 | 1053 | # Create a query set to use 1054 | self.queryset = SubscriptionQuerySet() 1055 | 1056 | @patch.object(SubscriptionQuerySet, 'select_related', autospec=True) 1057 | def test_cache_related(self, mock_select_related): 1058 | # Call the method 1059 | self.queryset.cache_related() 1060 | 1061 | # Assert that we called select related with the correct args 1062 | mock_select_related.assert_called_once_with( 1063 | self.queryset, 1064 | 'medium', 1065 | 'source', 1066 | 'entity', 1067 | 'sub_entity_kind' 1068 | ) 1069 | --------------------------------------------------------------------------------