├── testproject ├── __init__.py └── settings.py ├── user_streams ├── models.py ├── backends │ ├── __init__.py │ ├── user_streams_redis_backend │ │ ├── models.py │ │ ├── tests.py │ │ └── __init__.py │ ├── user_streams_single_table_backend │ │ ├── models.py │ │ ├── __init__.py │ │ └── tests.py │ ├── user_streams_many_to_many_backend │ │ ├── models.py │ │ ├── __init__.py │ │ └── tests.py │ └── dummy.py ├── compat.py ├── __init__.py ├── utils.py └── tests.py ├── requirements.txt ├── .gitignore ├── manage.py ├── .travis.yml ├── tox.ini ├── setup.py ├── LICENSE └── README.md /testproject/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /user_streams/models.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django>=1.3 2 | -------------------------------------------------------------------------------- /user_streams/backends/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /user_streams/backends/user_streams_redis_backend/models.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | *.egg-info/ 3 | .DS_Store 4 | .tox/ 5 | build/ 6 | dist/ 7 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os, sys 3 | 4 | if __name__ == "__main__": 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproject.settings") 6 | 7 | from django.core.management import execute_from_command_line 8 | 9 | execute_from_command_line(sys.argv) 10 | -------------------------------------------------------------------------------- /user_streams/backends/user_streams_single_table_backend/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class StreamItem(models.Model): 5 | 6 | user = models.ForeignKey('auth.User', related_name='+') 7 | content = models.TextField() 8 | created_at = models.DateTimeField() 9 | 10 | class Meta: 11 | ordering = ['-created_at'] 12 | -------------------------------------------------------------------------------- /user_streams/backends/user_streams_many_to_many_backend/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class StreamItem(models.Model): 5 | 6 | users = models.ManyToManyField('auth.User', related_name='+') 7 | content = models.TextField() 8 | created_at = models.DateTimeField() 9 | 10 | class Meta: 11 | ordering = ['-created_at'] 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: "2.7" 4 | 5 | env: 6 | - DJANGO=https://github.com/django/django/zipball/master 7 | - DJANGO=django==1.4.3 --use-mirrors 8 | - DJANGO=django==1.3.5 --use-mirrors 9 | 10 | install: 11 | - pip install $DJANGO 12 | - pip install redis 13 | - export PYTHONPATH=. 14 | 15 | script: 16 | - python manage.py test user_streams 17 | -------------------------------------------------------------------------------- /user_streams/compat.py: -------------------------------------------------------------------------------- 1 | try: 2 | from django.utils.timezone import now as datetime_now 3 | except ImportError: 4 | # Compat with previous 1.3 behavior 5 | from datetime import datetime 6 | from django.conf import settings 7 | if getattr(settings, 'USER_STREAMS_USE_UTC', False): 8 | datetime_now = datetime.utcnow 9 | else: 10 | datetime_now = datetime.now 11 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | downloadcache = {toxworkdir}/cache/ 3 | envlist = django1.5,django1.4,django1.3 4 | 5 | [testenv] 6 | commands = python manage.py test user_streams 7 | 8 | [testenv:django1.5] 9 | deps = https://github.com/django/django/zipball/master 10 | redis 11 | 12 | [testenv:django1.4] 13 | deps = django==1.4.3 14 | redis 15 | 16 | [testenv:django1.3] 17 | deps = django==1.3.5 18 | redis 19 | -------------------------------------------------------------------------------- /user_streams/backends/user_streams_single_table_backend/__init__.py: -------------------------------------------------------------------------------- 1 | from .models import StreamItem 2 | 3 | 4 | class SingleTableDatabaseBackend(object): 5 | 6 | def add_stream_item(self, users, content, created_at): 7 | for user in users: 8 | StreamItem.objects.create(user=user, content=content, created_at=created_at) 9 | 10 | def get_stream_items(self, user): 11 | return StreamItem.objects.filter(user=user) 12 | -------------------------------------------------------------------------------- /user_streams/backends/user_streams_many_to_many_backend/__init__.py: -------------------------------------------------------------------------------- 1 | from .models import StreamItem 2 | 3 | 4 | class ManyToManyDatabaseBackend(object): 5 | 6 | def add_stream_item(self, users, content, created_at): 7 | item = StreamItem.objects.create(content=content, created_at=created_at) 8 | item.users.add(*[user.pk for user in users]) 9 | 10 | def get_stream_items(self, user): 11 | return StreamItem.objects.filter(users=user) 12 | -------------------------------------------------------------------------------- /user_streams/backends/user_streams_many_to_many_backend/tests.py: -------------------------------------------------------------------------------- 1 | from user_streams import BACKEND_SETTING_NAME 2 | from user_streams.tests import StreamStorageTestMixin 3 | from user_streams.utils import TestCase, override_settings 4 | 5 | 6 | BACKEND_SETTINGS = {BACKEND_SETTING_NAME: 'user_streams.backends.user_streams_many_to_many_backend.ManyToManyDatabaseBackend'} 7 | 8 | 9 | @override_settings(**BACKEND_SETTINGS) 10 | class ManyToManyDatabaseBackendTestCase(TestCase, StreamStorageTestMixin): 11 | pass 12 | -------------------------------------------------------------------------------- /user_streams/backends/user_streams_single_table_backend/tests.py: -------------------------------------------------------------------------------- 1 | from user_streams import BACKEND_SETTING_NAME 2 | from user_streams.tests import StreamStorageTestMixin 3 | from user_streams.utils import TestCase, override_settings 4 | 5 | 6 | BACKEND_SETTINGS = {BACKEND_SETTING_NAME: 'user_streams.backends.user_streams_single_table_backend.SingleTableDatabaseBackend'} 7 | 8 | 9 | @override_settings(**BACKEND_SETTINGS) 10 | class SingleTableDatabaseBackendTestCase(TestCase, StreamStorageTestMixin): 11 | pass 12 | -------------------------------------------------------------------------------- /testproject/settings.py: -------------------------------------------------------------------------------- 1 | 2 | DATABASES = { 3 | 'default': { 4 | 'ENGINE': 'django.db.backends.sqlite3', 5 | 'NAME': ':memory:', 6 | }, 7 | } 8 | 9 | INSTALLED_APPS = [ 10 | 'django.contrib.contenttypes', 11 | 'django.contrib.auth', 12 | 'user_streams', 13 | 'user_streams.backends.user_streams_single_table_backend', 14 | 'user_streams.backends.user_streams_many_to_many_backend', 15 | 'user_streams.backends.user_streams_redis_backend', 16 | ] 17 | 18 | SECRET_KEY = 'foobar' 19 | -------------------------------------------------------------------------------- /user_streams/backends/user_streams_redis_backend/tests.py: -------------------------------------------------------------------------------- 1 | from user_streams import BACKEND_SETTING_NAME 2 | from user_streams.tests import StreamStorageTestMixin 3 | from user_streams.utils import TestCase, override_settings 4 | 5 | from . import Redis, KEY_PREFIX_SETTING_NAME 6 | 7 | 8 | KEY_PREFIX = 'redis_backend_tests' 9 | BACKEND_SETTINGS = { 10 | BACKEND_SETTING_NAME: 'user_streams.backends.user_streams_redis_backend.RedisBackend', 11 | KEY_PREFIX_SETTING_NAME: KEY_PREFIX, 12 | } 13 | 14 | 15 | @override_settings(**BACKEND_SETTINGS) 16 | class RedisBackendTestCase(TestCase, StreamStorageTestMixin): 17 | 18 | def tearDown(self): 19 | client = Redis() 20 | keys = client.keys('%s*' % KEY_PREFIX) 21 | if keys: 22 | client.delete(*keys) 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | import subprocess 7 | from setuptools import setup, find_packages 8 | 9 | 10 | version = '0.6.0' 11 | 12 | 13 | if sys.argv[-1] == 'publish': 14 | subprocess.call(['python', 'setup.py', 'sdist', 'upload']) 15 | print "You probably want to also tag the version now:" 16 | print " git tag -a %s -m 'Tag version %s'" % (version, version) 17 | print " git push --tags" 18 | sys.exit() 19 | 20 | 21 | setup( 22 | name='django-user-streams', 23 | version=version, 24 | description='Simple, fast user news feeds for Django', 25 | author='Jamie Matthews', 26 | url='https://github.com/dabapps/django-user-streams', 27 | packages=find_packages(exclude=['testproject']), 28 | license='BSD', 29 | ) 30 | -------------------------------------------------------------------------------- /user_streams/backends/dummy.py: -------------------------------------------------------------------------------- 1 | 2 | class DummyStreamItem(object): 3 | 4 | def __init__(self, content, created_at): 5 | self.content = content 6 | self.created_at = created_at 7 | 8 | 9 | class MemoryStorage(object): 10 | 11 | def __init__(self): 12 | self.streams = {} 13 | 14 | def add_stream_item(self, users, content, created_at): 15 | stream_item = DummyStreamItem(content, created_at) 16 | for user in users: 17 | if user in self.streams: 18 | self.streams[user].insert(0, stream_item) 19 | else: 20 | self.streams[user] = [stream_item] 21 | 22 | def get_stream_items(self, user): 23 | return self.streams.get(user, []) 24 | 25 | def flush(self): 26 | self.streams = {} 27 | 28 | 29 | storage = MemoryStorage() 30 | 31 | 32 | class DummyBackend(object): 33 | 34 | """ 35 | A dummy storage backend that stores user streams in memory. 36 | Only used for testing purposes. 37 | """ 38 | 39 | def add_stream_item(self, users, content, created_at): 40 | storage.add_stream_item(users, content, created_at) 41 | 42 | def get_stream_items(self, user): 43 | return storage.get_stream_items(user) 44 | 45 | def flush(self): 46 | storage.flush() 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) DabApps 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 17 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 18 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 19 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 20 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 21 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 22 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | 24 | The views and conclusions contained in the software and documentation are those 25 | of the authors and should not be interpreted as representing official policies, 26 | either expressed or implied, of DabApps. 27 | -------------------------------------------------------------------------------- /user_streams/__init__.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | from django.utils.importlib import import_module 3 | from user_streams.compat import datetime_now 4 | 5 | 6 | BACKEND_SETTING_NAME = 'USER_STREAMS_BACKEND' 7 | 8 | 9 | def get_backend(): 10 | """ 11 | Return the stream storage backend configured in the settings 12 | """ 13 | from django.conf import settings 14 | backend_path = getattr(settings, BACKEND_SETTING_NAME, None) 15 | if not backend_path: 16 | raise ImproperlyConfigured('No user stream storage backend has been configured. Please set %s correctly' % BACKEND_SETTING_NAME) 17 | 18 | try: 19 | module_name, class_name = backend_path.rsplit('.', 1) 20 | except ValueError: 21 | raise ImproperlyConfigured('%s is not a valid value for the %s setting' % (backend_path, BACKEND_SETTING_NAME)) 22 | 23 | try: 24 | module = import_module(module_name) 25 | except ImportError, e: 26 | raise ImproperlyConfigured('Error importing user stream backend %s: %s' % (backend_path, e)) 27 | 28 | try: 29 | cls = getattr(module, class_name) 30 | except AttributeError: 31 | raise ImproperlyConfigured('Error importing user stream backend class %s' % backend_path) 32 | 33 | return cls() 34 | 35 | 36 | def create_iterable(item_or_iterable): 37 | """ 38 | If the argument is iterable, just return it. Otherwise, return a list 39 | containing the item. 40 | """ 41 | try: 42 | iter(item_or_iterable) 43 | return item_or_iterable 44 | except TypeError: 45 | return [item_or_iterable] 46 | 47 | 48 | def add_stream_item(user_or_users, content, created_at=None): 49 | """ 50 | Add a single message to the stream of one or more users. 51 | """ 52 | backend = get_backend() 53 | users = create_iterable(user_or_users) 54 | created_at = created_at or datetime_now() 55 | backend.add_stream_item(users, content, created_at) 56 | 57 | 58 | def get_stream_items(user): 59 | """ 60 | Retrieve the stream for a single user. 61 | """ 62 | backend = get_backend() 63 | return backend.get_stream_items(user) 64 | -------------------------------------------------------------------------------- /user_streams/utils.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings, UserSettingsHolder 2 | from django.test import TestCase as DjangoTestCase 3 | from django.utils.functional import wraps 4 | 5 | 6 | class OverrideSettingsHolder(UserSettingsHolder): 7 | """ 8 | A custom setting holder that sends a signal upon change. 9 | """ 10 | def __setattr__(self, name, value): 11 | UserSettingsHolder.__setattr__(self, name, value) 12 | 13 | 14 | class override_settings(object): 15 | """ 16 | Acts as either a decorator, or a context manager. If it's a decorator it 17 | takes a function and returns a wrapped function. If it's a contextmanager 18 | it's used with the ``with`` statement. In either event entering/exiting 19 | are called before and after, respectively, the function/block is executed. 20 | """ 21 | def __init__(self, **kwargs): 22 | self.options = kwargs 23 | self.wrapped = settings._wrapped 24 | 25 | def __enter__(self): 26 | self.enable() 27 | 28 | def __exit__(self, exc_type, exc_value, traceback): 29 | self.disable() 30 | 31 | def __call__(self, test_func): 32 | from django.test import TransactionTestCase 33 | if isinstance(test_func, type) and issubclass(test_func, TransactionTestCase): 34 | original_pre_setup = test_func._pre_setup 35 | original_post_teardown = test_func._post_teardown 36 | def _pre_setup(innerself): 37 | self.enable() 38 | original_pre_setup(innerself) 39 | def _post_teardown(innerself): 40 | original_post_teardown(innerself) 41 | self.disable() 42 | test_func._pre_setup = _pre_setup 43 | test_func._post_teardown = _post_teardown 44 | return test_func 45 | else: 46 | @wraps(test_func) 47 | def inner(*args, **kwargs): 48 | with self: 49 | return test_func(*args, **kwargs) 50 | return inner 51 | 52 | def enable(self): 53 | override = OverrideSettingsHolder(settings._wrapped) 54 | for key, new_value in self.options.items(): 55 | setattr(override, key, new_value) 56 | settings._wrapped = override 57 | 58 | def disable(self): 59 | settings._wrapped = self.wrapped 60 | 61 | 62 | class TestCase(DjangoTestCase): 63 | 64 | """TestCase base class with settings override functionality copied from Django 1.4""" 65 | 66 | def settings(self, **kwargs): 67 | """ 68 | A context manager that temporarily sets a setting and reverts 69 | back to the original value when exiting the context. 70 | """ 71 | return override_settings(**kwargs) 72 | -------------------------------------------------------------------------------- /user_streams/backends/user_streams_redis_backend/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from redis import Redis 3 | except ImportError: 4 | raise ImportError('Please install the redis-py module (pip install redis)') 5 | 6 | 7 | from datetime import datetime 8 | from django.utils.encoding import smart_str, smart_unicode 9 | import time 10 | from uuid import uuid4 11 | 12 | 13 | KEY_PREFIX_SETTING_NAME = 'USER_STREAMS_REDIS_KEY_PREFIX' 14 | DEFAULT_KEY_PREFIX = 'user_streams' 15 | CLIENT_ARGUMENTS_SETTING_NAME = 'USER_STREAMS_REDIS_CLIENT_ARGUMENTS' 16 | 17 | 18 | def get_redis_client(): 19 | from django.conf import settings 20 | client_arguments = getattr(settings, CLIENT_ARGUMENTS_SETTING_NAME, {}) 21 | return Redis(**client_arguments) 22 | 23 | 24 | def create_key(key): 25 | from django.conf import settings 26 | prefix = getattr(settings, KEY_PREFIX_SETTING_NAME, DEFAULT_KEY_PREFIX) 27 | return "%s:%s" % (prefix, key) 28 | 29 | 30 | def add_header(content): 31 | """ 32 | We need to add a unique header to each message, as duplicate items 33 | will otherwise be overwritten 34 | """ 35 | return uuid4().hex + smart_str(content) 36 | 37 | 38 | def remove_header(content): 39 | return smart_unicode(content[32:]) 40 | 41 | 42 | class RedisBackend(object): 43 | 44 | def __init__(self): 45 | self.redis_client = get_redis_client() 46 | 47 | def add_stream_item(self, users, content, created_at): 48 | content = add_header(content) 49 | for user in users: 50 | key = create_key('user:%s' % user.pk) 51 | timestamp = time.mktime(created_at.timetuple()) 52 | self.redis_client.zadd(key, content, timestamp) 53 | 54 | def get_stream_items(self, user): 55 | return LazyResultSet(user) 56 | 57 | 58 | class LazyResultSet(object): 59 | 60 | def __init__(self, user): 61 | self.user = user 62 | self.start = 0 63 | self.stop = -1 64 | self._results = None 65 | 66 | @property 67 | def key(self): 68 | return create_key('user:%s' % self.user.pk) 69 | 70 | def clone(self): 71 | cloned = LazyResultSet(self.user) 72 | cloned.start = self.start 73 | cloned.stop = self.stop 74 | return cloned 75 | 76 | def load_results(self): 77 | client = get_redis_client() 78 | self._results = client.zrange( 79 | self.key, 80 | self.start, 81 | self.stop, 82 | desc=True, 83 | withscores=True 84 | ) 85 | 86 | def get_results(self): 87 | if self._results is None: 88 | self.load_results() 89 | return self._results 90 | 91 | def __len__(self): 92 | if self._results is not None: 93 | return len(self._results) 94 | 95 | if self.start == 0 and self.stop == -1: 96 | client = get_redis_client() 97 | return client.zcard(self.key) 98 | 99 | results = self.get_results() 100 | return len(results) 101 | 102 | def create_item(self, result): 103 | content, timestamp = result 104 | content = remove_header(content) 105 | created_at = datetime.fromtimestamp(timestamp) 106 | return StreamItem(content, created_at) 107 | 108 | def __getitem__(self, item): 109 | if isinstance(item, slice): 110 | clone = self.clone() 111 | clone.start = item.start or 0 112 | clone.stop = item.stop or 0 113 | clone.stop -= 1 114 | return clone 115 | else: 116 | return self.create_item(self.get_results()[item]) 117 | 118 | 119 | class StreamItem(object): 120 | 121 | def __init__(self, content, created_at): 122 | self.content = content 123 | self.created_at = created_at 124 | -------------------------------------------------------------------------------- /user_streams/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | from datetime import timedelta 5 | from django.contrib.auth.models import User 6 | from django.core.exceptions import ImproperlyConfigured 7 | from django.core.paginator import Paginator 8 | 9 | 10 | from user_streams import BACKEND_SETTING_NAME, get_backend, add_stream_item, get_stream_items 11 | from user_streams.backends.dummy import DummyBackend 12 | from user_streams.compat import datetime_now 13 | from user_streams.utils import TestCase, override_settings 14 | 15 | 16 | DUMMY_BACKEND_SETTINGS = {BACKEND_SETTING_NAME: 'user_streams.backends.dummy.DummyBackend'} 17 | 18 | 19 | class GetBackendTestCase(TestCase): 20 | 21 | def test_missing_setting(self): 22 | with self.assertRaises(ImproperlyConfigured): 23 | get_backend() 24 | 25 | def test_invalid_backend_path(self): 26 | settings = {BACKEND_SETTING_NAME: 'invalid'} 27 | with self.settings(**settings): 28 | with self.assertRaises(ImproperlyConfigured): 29 | get_backend() 30 | 31 | def test_incorrect_backend_path(self): 32 | settings = {BACKEND_SETTING_NAME: 'foo.bar.invalid.InvalidClass'} 33 | with self.settings(**settings): 34 | with self.assertRaises(ImproperlyConfigured): 35 | get_backend() 36 | 37 | def test_correct_backend_returned(self): 38 | with self.settings(**DUMMY_BACKEND_SETTINGS): 39 | backend = get_backend() 40 | self.assertTrue(isinstance(backend, DummyBackend)) 41 | 42 | 43 | class StreamStorageTestMixin(object): 44 | 45 | """ 46 | A mixin providing a set of test cases that can be run to test 47 | any backend. Note that the backend MUST be emptied (all messages 48 | should be removed) between each test. If a database backend 49 | is being tested, this will happen automatically. Otherwise, you 50 | are responsible for deleting all the messages between tests. 51 | """ 52 | 53 | def test_single_user(self): 54 | user = User.objects.create() 55 | content = 'Test message' 56 | 57 | add_stream_item(user, content) 58 | 59 | items = get_stream_items(user) 60 | self.assertEqual(len(items), 1) 61 | item = items[0] 62 | self.assertEqual(item.content, content) 63 | 64 | def test_multiple_users(self): 65 | user_1 = User.objects.create(username='test1') 66 | user_2 = User.objects.create(username='test2') 67 | user_3 = User.objects.create(username='test3') 68 | content = 'Broadcast message' 69 | 70 | add_stream_item(User.objects.all(), content) 71 | 72 | for user in user_1, user_2, user_3: 73 | self.assertEqual(get_stream_items(user)[0].content, content) 74 | 75 | def test_message_ordering(self): 76 | user = User.objects.create() 77 | now = datetime_now() 78 | 79 | add_stream_item(user, 'Message 1', created_at=now) 80 | add_stream_item(user, 'Message 2', created_at=now + timedelta(minutes=1)) 81 | add_stream_item(user, 'Message 3', created_at=now + timedelta(minutes=2)) 82 | 83 | stream_items = get_stream_items(user) 84 | 85 | self.assertEqual(stream_items[0].content, 'Message 3') 86 | self.assertEqual(stream_items[1].content, 'Message 2') 87 | self.assertEqual(stream_items[2].content, 'Message 1') 88 | 89 | def test_slicing(self): 90 | user = User.objects.create() 91 | now = datetime_now() 92 | 93 | for count in range(10): 94 | created_at = now + timedelta(minutes=count) 95 | add_stream_item(user, 'Message %s' % count, created_at=created_at) 96 | 97 | stream_items = get_stream_items(user) 98 | 99 | first_five = stream_items[:5] 100 | self.assertEqual(len(first_five), 5) 101 | self.assertEqual(first_five[0].content, 'Message 9') 102 | self.assertEqual(first_five[4].content, 'Message 5') 103 | 104 | middle = stream_items[3:7] 105 | self.assertEqual(len(middle), 4) 106 | self.assertEqual(middle[0].content, 'Message 6') 107 | self.assertEqual(middle[3].content, 'Message 3') 108 | 109 | end = stream_items[6:] 110 | self.assertEqual(len(end), 4) 111 | self.assertEqual(end[0].content, 'Message 3') 112 | self.assertEqual(end[3].content, 'Message 0') 113 | 114 | def test_pagination(self): 115 | user = User.objects.create() 116 | now = datetime_now() 117 | 118 | for count in range(100): 119 | created_at = now + timedelta(minutes=count) 120 | add_stream_item(user, 'Message %s' % count, created_at=created_at) 121 | 122 | paginator = Paginator(get_stream_items(user), 10) 123 | self.assertEqual(paginator.num_pages, 10) 124 | 125 | page_1 = paginator.page(1) 126 | objects = page_1.object_list 127 | self.assertEqual(len(objects), 10) 128 | self.assertEqual(objects[0].content, 'Message 99') 129 | self.assertEqual(objects[9].content, 'Message 90') 130 | self.assertEqual(page_1.next_page_number(), 2) 131 | 132 | page_10 = paginator.page(10) 133 | objects = page_10.object_list 134 | self.assertEqual(len(objects), 10) 135 | self.assertEqual(objects[0].content, 'Message 9') 136 | self.assertEqual(objects[9].content, 'Message 0') 137 | self.assertFalse(page_10.has_next()) 138 | 139 | def test_identical_messages(self): 140 | """Check that identical messages are handled properly. Mostly 141 | an issue for the Redis backend (which uses sets to store messages)""" 142 | user = User.objects.create() 143 | message = 'Test message' 144 | 145 | add_stream_item(user, message) 146 | add_stream_item(user, message) 147 | 148 | items = get_stream_items(user) 149 | self.assertEqual(len(items), 2) 150 | 151 | def test_unicode_handled_properly(self): 152 | user = User.objects.create() 153 | message = u'☃' 154 | 155 | add_stream_item(user, message) 156 | 157 | items = get_stream_items(user) 158 | self.assertEqual(items[0].content, message) 159 | 160 | 161 | 162 | @override_settings(**DUMMY_BACKEND_SETTINGS) 163 | class DummyBackendStreamTestCase(TestCase, StreamStorageTestMixin): 164 | 165 | def setUp(self): 166 | dummy_backend = get_backend() 167 | dummy_backend.flush() 168 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-user-streams 2 | 3 | **Simple, fast user news feeds for Django** 4 | 5 | **Author:** Jamie Matthews. [Follow me on Twitter](http://twitter.com/j4mie). 6 | 7 | [![build-status-image]][travis] 8 | 9 | ## Requirements 10 | 11 | * Django 1.3, 1.4, 1.5 12 | 13 | ## Overview 14 | 15 | An app for creating *news feeds* (also known as *activity streams*) for users, 16 | notifying them of activity happening around your site. Optimised for speed, 17 | pluggability and simplicity. 18 | 19 | News feed items are stored as a string and a timestamp. You can't store any 20 | additional metadata about the stream items, such as generic foreign keys to and 21 | `Actor` or a `Target`. You just store the item content as plain text (or HTML). 22 | If you need links to other objects, just insert an `` tag. 23 | 24 | ## DEPRECATED 25 | **PLEASE NOTE:** _This repository is no longer actively maintained or regularly used by DabApps and therefore should be considered deprecated. Please find alternative packages for your needs or feel free to create and maintain your own fork._ 26 | 27 | ## Installation 28 | 29 | You can install django-user-streams from PyPI: 30 | 31 | pip install django-user-streams 32 | 33 | Add `user_streams` to your `INSTALLED_APPS` setting. You also need a *backend*, 34 | which defines how your streams are stored. These are described below. 35 | 36 | INSTALLED_APPS = [ 37 | ... 38 | 'user_streams', 39 | 'user_streams.backends.user_streams_single_table_backend', 40 | ... 41 | ] 42 | 43 | USER_STREAMS_BACKEND = 'user_streams.backends.user_streams_single_table_backend.SingleTableDatabaseBackend' 44 | 45 | Finally, if you're using a backend that stores stream items using Django's model 46 | layer, run `manage.py syncdb` to create the necessary database tables. 47 | 48 | ## API 49 | 50 | ### Adding items to streams 51 | 52 | To create a stream item: 53 | 54 | import user_streams 55 | 56 | user = User.objects.get(username='jamie') 57 | user_streams.add_stream_item(user, 'This is the contents of the stream item') 58 | 59 | The first argument to `add_stream_item` can be a single `User` instance, or a queryset 60 | representing multiple users. In the latter case, the message you supply is added 61 | to the stream of each user in the queryset. 62 | 63 | import user_streams 64 | 65 | user_streams.add_stream_item(User.objects.all(), 'Broadcast message to all users') 66 | 67 | You can also specify the creation time for the stream item by passing a 68 | `datetime.datetime` instance as the value of the `created_at` argument. 69 | 70 | python 71 | import user_streams 72 | from datetime import datetime 73 | 74 | user = User.objects.get(username='jamie') 75 | user_streams.add_stream_item(user, 'You have a new message!', created_at=datetime.now()) 76 | 77 | #### A note on time zones 78 | 79 | When a stream item is created, Django's [timezone support settings][use_tz] will be respected. 80 | 81 | If timezone support is enabled by setting `USE_TZ` to `True`, then timezone-aware datestamps will be used, and stream items will be stored in the database using a UTC offset. You will need to convert the timestamps to your users' local time at the last possible moment (when the `datetime` object is formatted for presentation to the user). 82 | 83 | If timezone support is disabled by setting `USE_TZ` to `False`, then timezone-naive datestamps will be used, and stream items should be dealt with as using localtime. 84 | 85 | #### Time zones and Django 1.3 compatibility 86 | 87 | Django's timezone support was added in 1.4, so things work a little differently if you're using `django-user-streams` with Django 1.3. 88 | 89 | By default, if you don't pass a `created_at` argument to `add_stream_item`, the 90 | value of `datetime.datetime.now()` will be used to timestamp your stream items. 91 | This is probably the least surprising behaviour, and if your app only ever deals 92 | with users in one timezone (and those users are in the same timezone as your 93 | web server), it's probably fine. 94 | 95 | If your users are all over the world, however, this is a bad idea. The reasons 96 | for this are discussed in 97 | [this blog post by Armin Ronacher](http://lucumr.pocoo.org/2011/7/15/eppur-si-muove/). 98 | The best way to store timestamps in the database is to use the UTC timezone. 99 | You can then convert them to your users' local time at the last possible moment 100 | (when the `datetime` object is formatted for presentation to the user). 101 | 102 | To support this, you can either provide the `created_at` argument every time 103 | you call the `add_stream_item` method: 104 | 105 | user_streams.add_stream_item(user, 'You have a new message!', 106 | created_at=datetime.utcnow()) 107 | 108 | Alternatively, you can set the `USER_STREAMS_USE_UTC` setting (in your 109 | `settings.py`) to `True` (it's `False` by default). If you do this, 110 | `datetime.utcnow()` will be used instead of `datetime.now()` to generate 111 | the timestamps for each stream item. 112 | 113 | If you do either of these things, the `created_at` property of each of your 114 | stream items will be set to UTC time. It's your responsibility to convert 115 | this to each user's local time for formatting. Take a look at 116 | [times](https://github.com/nvie/times) for an easy way to deal with that. 117 | 118 | Support for Django 1.3 and the `USER_STREAMS_USE_UTC` setting is intended to be deperecated at some point in the future. 119 | 120 | ### Getting the stream for a user 121 | 122 | To retrieve the stream items for a user: 123 | 124 | import user_streams 125 | 126 | user = User.objects.get(username='jamie') 127 | items = user_streams.get_stream_items(user) 128 | 129 | This will return an iterable of objects, each of which is guaranteed to have two 130 | properties: `created_at`, which will be a `datetime.datetime` instance 131 | representing the creation timestamp of the message, and `content`, which will 132 | contain the contents of the message as a string. The objects will be ordered by 133 | their `created_at` field, with the most recent first. The iterable that is 134 | returned will be *lazy*, meaning that you can slice it (and pass it to a Django 135 | `Paginator` object) without loading all of the items from the database. 136 | 137 | ### Backends 138 | 139 | Stream storage is abstracted into `Backend` classes. Three backends are 140 | included with `django-user-streams`. Each backend is kept in a separate 141 | reusable app, which must be added to `INSTALLED_APPS` separately to the main 142 | `user_streams` app. This is to ensure that only the database tables required 143 | for each backend are created (assuming you are using a backend that stores 144 | data through Django's model layer). 145 | 146 | Which backend you choose depends on the scale of your application, as well as 147 | your expected usage patterns. The pros and cons of each are described below. 148 | 149 | #### SingleTableDatabaseBackend 150 | 151 | `user_streams.backends.user_streams_single_table_backend.SingleTableDatabaseBackend` 152 | 153 | The simplest backend. Your stream items are stored in a single database table, 154 | consisting of a foreign key to a `User` object, a `DateTimeField` timestamp, and 155 | a `TextField` to store your message. Fetching a stream for a user should be 156 | extremely fast, as no database joins are involved. The tradeoff is storage 157 | space: If you send a message to multiple users, the message is stored multiple 158 | times, once for each user. If you regularly broadcast messages to thousands of 159 | users, you may find that the table gets very large. 160 | 161 | #### ManyToManyDatabaseBackend 162 | 163 | `user_streams.backends.user_streams_many_to_many_backend.ManyToManyDatabaseBackend` 164 | 165 | This backend stores your messages in a table with a `ManyToManyField` 166 | relationship to your `User` objects. Each message is only stored *once*, with a 167 | row in the intermediate table for each recipient. This means you need much less 168 | space for broadcast messages, but your queries may be slightly slower. 169 | 170 | #### RedisBackend 171 | 172 | `user_streams.backends.user_streams_redis_backend.RedisBackend` 173 | 174 | Stores your messages in Redis sorted sets, one set for each user, with a Unix 175 | timestamp (the `created_at` attribute) as the score for each item. This approach 176 | is described in more detail 177 | [here](http://blog.waxman.me/how-to-build-a-fast-news-feed-in-redis). The 178 | iterable returned by `get_stream_items` uses `ZREVRANGE` to retrieve each slice of the 179 | feed, and `ZCARD` to get the complete size of the set of items. This backend 180 | should be screamingly fast. 181 | 182 | *Note: the Redis backend requires the `redis-py` library. Install with `pip 183 | install redis`.* 184 | 185 | ##### Redis backend settings 186 | 187 | The following settings control the behaviour of the Redis backend: 188 | 189 | `USER_STREAMS_REDIS_KEY_PREFIX` 190 | 191 | Each key generated by the backend will be prefixed with the value of this 192 | setting. The default prefix is "user_streams". 193 | 194 | `USER_STREAMS_REDIS_CLIENT_ARGUMENTS` 195 | 196 | A dictionary of keyword arguments which will be passed to the constructor 197 | of the Redis client instance. 198 | 199 | #### Writing your own backend 200 | 201 | You can create your own backend to store messages in whatever data store suits 202 | your application. Backends are simple classes which must implement two methods: 203 | 204 | ##### add_stream_item 205 | 206 | `add_stream_item(self, users, content, created_at)` 207 | 208 | `users` will be an *iterable* of `User` instances (you don't need to worry 209 | about accepting a single instance - your backend method will always be called 210 | with an iterable, which may be a list containing only one `User`. 211 | 212 | `content` will be a string containing the stream message to store. 213 | 214 | `created_at` will be a Python `datetime.datetime` object representing the 215 | time at which the stream item was created. 216 | 217 | ##### get_stream_items 218 | 219 | `get_stream_items(self, user)` 220 | 221 | This method should return an iterable of messages for the given `User`, sorted 222 | by timestamp with the newest first. Each item must be an object with two 223 | attributes: `created_at` (which must be a Python `datetime.datetime` object) 224 | and `content` (which must be a string containing the message contents). 225 | 226 | While this method could simply return a `list` of messages, it's much more 227 | efficient to assume that the list will be paginated in some way, and support 228 | slicing and counting the objects on-demand, in whatever method your data store 229 | supports. To do this, you should return an iterable object, overriding 230 | `__getitem__` and `__len__`. See the implementation of `RedisBackend` for an 231 | example. 232 | 233 | ## Alternatives 234 | 235 | https://github.com/justquick/django-activity-stream 236 | 237 | ## Development 238 | 239 | To contribute: fork the repository, make your changes, add some tests, commit, 240 | push to a feature branch, and open a pull request. 241 | 242 | ### How to run the tests 243 | 244 | Clone the repo, install the requirements into your virtualenv, then type 245 | `python manage.py test user_streams`. You can also use 246 | `python manage.py test user_streams user_streams_single_table_backend user_streams_many_to_many_backend user_streams_redis_backend` to 247 | run the tests for all the backends. Any of the above should also work if 248 | you've installed `django-user-streams` into an existing Django project (of 249 | course, only run the tests for the backend you're using). 250 | 251 | ## Changelog 252 | 253 | #### 0.6.0 254 | 255 | * Add compatibility with Django 1.4's support for timezones 256 | 257 | #### 0.5.0 258 | 259 | * Backends renamed to make app_labels less generic (for example, `many_to_many` 260 | is now `user_streams_many_to_many_backend`). 261 | 262 | #### 0.4.0 263 | 264 | * Add tests for pagination of results 265 | * Fix result loading in RedisBackend 266 | 267 | #### 0.3.0 268 | 269 | * Fix slicing behaviour in Redis backend 270 | 271 | #### 0.2.0 272 | 273 | * Fix packaging 274 | 275 | #### 0.1.0 276 | 277 | * Initial release. 278 | 279 | ## License 280 | 281 | Copyright (c) DabApps 282 | All rights reserved. 283 | 284 | Redistribution and use in source and binary forms, with or without 285 | modification, are permitted provided that the following conditions are met: 286 | 287 | 1. Redistributions of source code must retain the above copyright notice, this 288 | list of conditions and the following disclaimer. 289 | 2. Redistributions in binary form must reproduce the above copyright notice, 290 | this list of conditions and the following disclaimer in the documentation 291 | and/or other materials provided with the distribution. 292 | 293 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 294 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 295 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 296 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 297 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 298 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 299 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 300 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 301 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 302 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 303 | 304 | The views and conclusions contained in the software and documentation are those 305 | of the authors and should not be interpreted as representing official policies, 306 | either expressed or implied, of DabApps. 307 | 308 | [build-status-image]: https://secure.travis-ci.org/dabapps/django-user-streams.png?branch=master 309 | [travis]: http://travis-ci.org/dabapps/django-user-streams?branch=master 310 | [use_tz]: https://docs.djangoproject.com/en/1.4/topics/i18n/timezones/ 311 | --------------------------------------------------------------------------------