├── connections ├── templatetags │ ├── __init__.py │ └── connections.py ├── apps.py ├── __init__.py ├── signals.py ├── shortcuts.py └── models.py ├── INSTALL ├── .coveragerc ├── .gitignore ├── runtests.sh ├── MANIFEST.in ├── tests ├── settings.py ├── test_signals.py ├── __init__.py ├── test_relationships.py ├── test_templatetags.py └── test_connections.py ├── .travis.yml ├── runtests.py ├── LICENSE ├── setup.py └── README.rst /connections/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /INSTALL: -------------------------------------------------------------------------------- 1 | 2 | Please use:: 3 | 4 | $ python setup.py install 5 | 6 | See README.rst 7 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | pragma: no cover 4 | omit = 5 | */python?.?/* 6 | */site-packages/nose/* 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | *~ 4 | ._* 5 | .coverage 6 | .DS_Store 7 | .Python 8 | __pycache__ 9 | dist/ 10 | docs/_build 11 | pip-log.txt 12 | -------------------------------------------------------------------------------- /runtests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | coverage run --source=connections runtests.py --nologcapture --nocapture "$@" 3 | result=$? 4 | echo 5 | coverage report -m 6 | echo 7 | exit $result 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include INSTALL 2 | include LICENSE 3 | include README.rst 4 | recursive-include tests * 5 | global-exclude *.pyc 6 | global-exclude .coverage 7 | global-exclude .DS_Store 8 | -------------------------------------------------------------------------------- /connections/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ConnectionsConfig(AppConfig): 5 | name = 'connections' 6 | 7 | 8 | class AutodiscoverConnectionsConfig(ConnectionsConfig): 9 | def ready(self): 10 | from django.utils.module_loading import autodiscover_modules 11 | autodiscover_modules('relationships') 12 | -------------------------------------------------------------------------------- /connections/__init__.py: -------------------------------------------------------------------------------- 1 | from .shortcuts import ( 2 | define_relationship, 3 | get_relationship, 4 | get_connection, 5 | create_connection, 6 | connection_exists, 7 | connections_from_object, 8 | connections_to_object, 9 | connected_objects, 10 | connected_to_objects, 11 | ) 12 | 13 | VERSION = (0, 2, 0, 'final', 1) 14 | 15 | default_app_config = 'connections.apps.ConnectionsConfig' 16 | -------------------------------------------------------------------------------- /connections/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | 4 | # sent just after a new connection is created and saved to the database. 5 | # sender is the connection's relationship. 6 | connection_created = Signal(providing_args=['connection']) 7 | 8 | 9 | # sent just after a connection is deleted. 10 | # sender is the connection's relationship. 11 | connection_removed = Signal(providing_args=['connection']) 12 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | DEBUG = True 2 | TEMPLATE_DEBUG = DEBUG 3 | 4 | ADMINS = ( 5 | ('test@example.com', 'Administrator'), 6 | ) 7 | 8 | DATABASES = { 9 | 'default': { 10 | 'ENGINE': 'django.db.backends.sqlite3', 11 | 'NAME': ':memory:', 12 | }, 13 | } 14 | 15 | INSTALLED_APPS = ( 16 | 'django.contrib.auth', 17 | 'django.contrib.contenttypes', 18 | 'connections', 19 | 'tests', 20 | ) 21 | 22 | CACHE_BACKEND = 'locmem://' 23 | 24 | SECRET_KEY = 'thats-a-secret' 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.6" 4 | - "2.7" 5 | - "3.2" 6 | - "3.3" 7 | env: 8 | - DJANGO='Django<1.6' 9 | - DJANGO='Django<1.7' 10 | - DJANGO='https://github.com/django/django/archive/1.7b1.tar.gz' 11 | matrix: 12 | exclude: 13 | - python: "2.6" 14 | env: "DJANGO='https://github.com/django/django/archive/1.7b1.tar.gz'" 15 | install: 16 | - 'pip install "${DJANGO}"' 17 | - pip install coveralls 18 | - pip install . 19 | script: ./runtests.sh -v 20 | after_success: 21 | - coveralls 22 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | from os import environ 5 | from os.path import abspath, dirname 6 | 7 | import nose 8 | 9 | 10 | def main(): 11 | environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' 12 | 13 | # setup path 14 | test_dir = dirname(dirname(abspath(__file__))) 15 | sys.path.insert(0, test_dir) 16 | 17 | try: 18 | # django >= 1.7 19 | from django import setup 20 | except ImportError: 21 | pass 22 | else: 23 | setup() 24 | 25 | # setup test env 26 | from django.test.utils import setup_test_environment 27 | setup_test_environment() 28 | 29 | # setup db 30 | from django.core.management import call_command, CommandError 31 | options = { 32 | 'interactive': False, 33 | 'verbosity': 1, 34 | } 35 | try: 36 | call_command('migrate', **options) 37 | except CommandError: # Django < 1.7 38 | call_command('syncdb', **options) 39 | 40 | # run tests 41 | return nose.main() 42 | 43 | 44 | if __name__ == '__main__': 45 | main() 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Akis Kesoglou 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /connections/shortcuts.py: -------------------------------------------------------------------------------- 1 | 2 | def define_relationship(name, from_model, to_model): 3 | from .models import define_relationship as _define_relationship 4 | return _define_relationship(name, from_model, to_model) 5 | 6 | 7 | def get_relationship(name): 8 | from .models import get_relationship as _get_relationship 9 | return _get_relationship(name) 10 | 11 | 12 | def get_connection(relationship, from_obj, to_obj): 13 | return get_relationship(relationship).get_connection(from_obj, to_obj) 14 | 15 | 16 | def create_connection(relationship, from_obj, to_obj): 17 | return get_relationship(relationship).create_connection(from_obj, to_obj) 18 | 19 | 20 | def connection_exists(relationship, from_obj, to_obj): 21 | return get_relationship(relationship).connection_exists(from_obj, to_obj) 22 | 23 | 24 | def connections_from_object(relationship, from_obj): 25 | return get_relationship(relationship).connections_from_object(from_obj) 26 | 27 | 28 | def connections_to_object(relationship, to_obj): 29 | return get_relationship(relationship).connections_to_object(to_obj) 30 | 31 | 32 | def connected_objects(relationship, from_obj): 33 | return get_relationship(relationship).connected_objects(from_obj) 34 | 35 | 36 | def connected_to_objects(relationship, to_obj): 37 | return get_relationship(relationship).connected_to_objects(to_obj) 38 | -------------------------------------------------------------------------------- /connections/templatetags/connections.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | from ..models import get_relationship 4 | 5 | 6 | register = template.Library() 7 | 8 | 9 | @register.assignment_tag 10 | def get_connection_distance(relationship, obj1, obj2, limit=2): 11 | """ 12 | Calculates the distance between the two given objects for the given 13 | relationship. See `connections.models.Relationship` for more info. 14 | 15 | {% get_connection_distance 'relationship_name' obj1 obj2 as distance %} 16 | {% get_connection_distance 'relationship_name' obj1 obj2 limit=3 as distance %} 17 | 18 | """ 19 | return get_relationship(relationship).distance_between(obj1, obj2, limit) 20 | 21 | 22 | @register.assignment_tag 23 | def connections_from_object(relationship, obj1): 24 | """ 25 | {% connections_from_object 'relationship_name' obj1 as connections %} 26 | """ 27 | return get_relationship(relationship).connections_from_object(obj1) 28 | 29 | 30 | @register.assignment_tag 31 | def connections_to_object(relationship, obj1): 32 | """ 33 | {% connections_to_object 'relationship_name' obj1 as connections %} 34 | """ 35 | return get_relationship(relationship).connections_to_object(obj1) 36 | 37 | 38 | @register.assignment_tag 39 | def connection_exists(relationship, obj1, obj2): 40 | """ 41 | {% connection_exists 'relationship_name' obj1 obj2 as connections %} 42 | """ 43 | return get_relationship(relationship).connection_exists(obj1, obj2) 44 | -------------------------------------------------------------------------------- /tests/test_signals.py: -------------------------------------------------------------------------------- 1 | from nose.tools import with_setup 2 | 3 | from django.contrib.auth.models import User 4 | 5 | from connections.models import _relationship_registry as registry 6 | from connections.shortcuts import define_relationship, create_connection 7 | from connections.signals import connection_created, connection_removed 8 | 9 | 10 | def reset_registry(d): 11 | def fn(): 12 | for k in list(d.keys()): 13 | d.pop(k) 14 | return fn 15 | 16 | 17 | @with_setup(reset_registry(registry), reset_registry(registry)) 18 | def test_connection_created(): 19 | foo = User.objects.create_user(username='foo') 20 | bar = User.objects.create_user(username='bar') 21 | r = define_relationship('rel', User, User) 22 | 23 | def handler(signal, sender, connection, **kwargs): 24 | assert sender is r 25 | assert connection.relationship is r 26 | assert connection.from_object == foo 27 | assert connection.to_object == bar 28 | assert kwargs == {} 29 | 30 | c = None 31 | try: 32 | connection_created.connect(handler, sender=r) 33 | c = create_connection(r, foo, bar) 34 | finally: 35 | connection_created.disconnect(handler) 36 | c and c.delete() 37 | foo.delete() 38 | bar.delete() 39 | 40 | 41 | @with_setup(reset_registry(registry), reset_registry(registry)) 42 | def test_connection_removed(): 43 | foo = User.objects.create_user(username='foo') 44 | bar = User.objects.create_user(username='bar') 45 | r = define_relationship('rel', User, User) 46 | 47 | def handler(signal, sender, connection, **kwargs): 48 | assert sender is r 49 | assert connection.relationship is r 50 | assert connection.from_object == foo 51 | assert connection.to_object == bar 52 | assert kwargs == {} 53 | 54 | try: 55 | connection_removed.connect(handler, sender=r) 56 | c = create_connection(r, foo, bar) 57 | c.delete() 58 | finally: 59 | connection_removed.disconnect(handler) 60 | foo.delete() 61 | bar.delete() 62 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from nose.tools import assert_raises_regex 3 | except ImportError: 4 | try: 5 | from nose.tools import assert_raises_regexp as assert_raises_regex 6 | except ImportError: 7 | import re 8 | import nose.tools 9 | 10 | # copied from python-2.7-src/Lib/unittest/case.py 11 | class AssertRaisesContext(object): 12 | def __init__(self, expected, expected_regexp=None): 13 | self.expected = expected 14 | self.expected_regexp = expected_regexp 15 | 16 | def __enter__(self): 17 | return self 18 | 19 | def __exit__(self, exc_type, exc_value, tb): 20 | if exc_type is None: 21 | try: 22 | exc_name = self.expected.__name__ 23 | except AttributeError: 24 | exc_name = str(self.expected) 25 | nose.tools.fail('%s not raised' % exc_name) 26 | if not issubclass(exc_type, self.expected): 27 | # let unexpected exceptions pass through 28 | return False 29 | self.exception = exc_value # store for later retrieval 30 | if self.expected_regexp is None: 31 | return True 32 | expected_regexp = self.expected_regexp 33 | if isinstance(expected_regexp, basestring): 34 | expected_regexp = re.compile(expected_regexp) 35 | if not expected_regexp.search(str(exc_value)): 36 | nose.tools.fail('%s does not match' % ( 37 | expected_regexp.pattern, str(exc_value))) 38 | return True 39 | 40 | def assert_raises_regex(expected, regexp, callable_obj=None, *args, **kwargs): 41 | context = AssertRaisesContext(expected, regexp) 42 | if callable_obj is None: 43 | return context 44 | with context: 45 | callable_obj(*args, **kwargs) 46 | 47 | nose.tools.assert_raises_regex = assert_raises_regex 48 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from os.path import dirname, join 3 | 4 | try: 5 | from setuptools import setup 6 | except ImportError: 7 | from distutils.core import setup 8 | 9 | from connections import VERSION 10 | 11 | 12 | def get_version(version): 13 | assert len(version) == 5 14 | assert version[3] in ('alpha', 'beta', 'rc', 'final') 15 | parts = 2 if version[2] == 0 else 3 16 | main = '.'.join(str(x) for x in version[:parts]) 17 | sub = '' 18 | if version[3] != 'final': 19 | mapping = {'alpha': 'a', 'beta': 'b', 'rc': 'c'} 20 | sub = mapping[version[3]] + str(version[4]) 21 | return main + sub 22 | 23 | 24 | with open(join(dirname(__file__), 'README.rst')) as f: 25 | long_description = f.read() 26 | 27 | 28 | setup( 29 | name='django-connections', 30 | description='Create, query and manage graphs of relationships between your Django models', 31 | version=get_version(VERSION), 32 | long_description=long_description, 33 | 34 | url='http://github.com/dfunckt/django-connections', 35 | author='Akis Kesoglou', 36 | author_email='akiskesoglou@gmail.com', 37 | maintainer='Akis Kesoglou', 38 | maintainer_email='akiskesoglou@gmail.com', 39 | license='MIT', 40 | 41 | zip_safe=False, 42 | packages=[ 43 | 'connections', 44 | 'connections.templatetags', 45 | ], 46 | 47 | install_requires=[ 48 | 'Django >= 1.5', 49 | ], 50 | tests_require=[ 51 | 'nose', 52 | ], 53 | 54 | classifiers=[ 55 | 'Development Status :: 4 - Beta', 56 | 'Environment :: Web Environment', 57 | 'Framework :: Django', 58 | 'Intended Audience :: Developers', 59 | 'License :: OSI Approved :: MIT License', 60 | 'Operating System :: OS Independent', 61 | 'Programming Language :: Python', 62 | 'Programming Language :: Python :: 2', 63 | 'Programming Language :: Python :: 2.6', 64 | 'Programming Language :: Python :: 2.7', 65 | 'Programming Language :: Python :: 3', 66 | 'Programming Language :: Python :: 3.2', 67 | 'Programming Language :: Python :: 3.3', 68 | ], 69 | ) 70 | -------------------------------------------------------------------------------- /tests/test_relationships.py: -------------------------------------------------------------------------------- 1 | from nose.tools import assert_raises, with_setup 2 | 3 | try: 4 | from nose.tools import assert_raises_regex 5 | except ImportError: 6 | from nose.tools import assert_raises_regexp as assert_raises_regex 7 | 8 | from django.contrib.auth.models import User 9 | 10 | from connections.models import (NAME_MAX_LENGTH, get_model, 11 | Relationship, Connection, _relationship_registry as registry) 12 | from connections.shortcuts import define_relationship, get_relationship 13 | 14 | 15 | def reset_registry(d): 16 | def fn(): 17 | for k in list(d.keys()): 18 | d.pop(k) 19 | return fn 20 | 21 | 22 | def test_get_model(): 23 | assert Connection is get_model(Connection) 24 | assert Connection is get_model('connections.Connection') 25 | assert_raises_regex(ValueError, "^<(class|type) 'object'>$", get_model, object) 26 | assert_raises_regex(ValueError, '^invalidmodelname$', get_model, 'invalidmodelname') 27 | assert_raises_regex(ValueError, '^invalid\.Model$', get_model, 'invalid.Model') 28 | 29 | 30 | @with_setup(reset_registry(registry), reset_registry(registry)) 31 | def test_define_relationship(): 32 | r1 = define_relationship('rel1', 'auth.User', 'auth.User') 33 | assert 'rel1' in registry 34 | assert r1 in registry.values() 35 | assert isinstance(r1, Relationship) 36 | assert r1.name == 'rel1' 37 | assert r1.from_content_type.model_class() is User 38 | assert r1.to_content_type.model_class() is User 39 | assert str(r1) == 'rel1 (user -> user)' 40 | 41 | r2 = define_relationship('rel2', User, User) 42 | assert 'rel2' in registry 43 | assert r2 in registry.values() 44 | assert isinstance(r2, Relationship) 45 | assert r2.name == 'rel2' 46 | assert r2.from_content_type.model_class() is User 47 | assert r2.to_content_type.model_class() is User 48 | assert str(r2) == 'rel2 (user -> user)' 49 | 50 | 51 | @with_setup(reset_registry(registry), reset_registry(registry)) 52 | def test_define_relationship_raises_for_duplicate(): 53 | define_relationship('r1', User, User) 54 | assert_raises_regex(KeyError, "^'r1'$", define_relationship, 'r1', User, User) 55 | 56 | 57 | @with_setup(reset_registry(registry), reset_registry(registry)) 58 | def test_relationship_name_max_length(): 59 | assert_raises(AssertionError, define_relationship, 'x' * (NAME_MAX_LENGTH + 1), User, User) 60 | define_relationship('x' * NAME_MAX_LENGTH, User, User) 61 | 62 | 63 | @with_setup(reset_registry(registry), reset_registry(registry)) 64 | def test_get_relationship(): 65 | r = define_relationship('rel', User, User) 66 | assert r is get_relationship('rel') 67 | assert r is get_relationship(r) 68 | assert_raises(Relationship.DoesNotExist, get_relationship, 'invalid') 69 | -------------------------------------------------------------------------------- /tests/test_templatetags.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.template import Template, Context 3 | from django.test import TestCase 4 | 5 | from connections.models import Connection, _relationship_registry as registry 6 | from connections.shortcuts import (define_relationship, get_relationship, 7 | create_connection) 8 | 9 | 10 | def reset_registry(d): 11 | for k in list(d.keys()): 12 | d.pop(k) 13 | 14 | 15 | def reset_relationship(r): 16 | Connection.objects.filter(relationship_name=get_relationship(r).name).delete() 17 | 18 | 19 | class ConnectionTests(TestCase): 20 | def setUp(self): 21 | reset_registry(registry) 22 | self.r = define_relationship('user_follow', User, User) 23 | self.foo = User.objects.create_user(username='foo') 24 | self.bar = User.objects.create_user(username='bar') 25 | self.jaz = User.objects.create_user(username='jaz') 26 | reset_relationship('user_follow') 27 | 28 | def tearDown(self): 29 | reset_relationship('user_follow') 30 | reset_registry(registry) 31 | self.foo.delete() 32 | self.bar.delete() 33 | self.jaz.delete() 34 | 35 | def test_get_connection_distance(self): 36 | tpl = """{%% spaceless %%} 37 | {%% load connections %%} 38 | {%% get_connection_distance %s foo bar as distance %%} 39 | {{ distance }} 40 | {%% endspaceless %%}""" 41 | 42 | create_connection(self.r, self.foo, self.bar) 43 | 44 | assert '1' == Template(tpl % "'user_follow'").render(Context({ 45 | 'foo': self.foo, 46 | 'bar': self.bar, 47 | })) 48 | 49 | assert '1' == Template(tpl % "rel").render(Context({ 50 | 'rel': self.r, 51 | 'foo': self.foo, 52 | 'bar': self.bar, 53 | })) 54 | 55 | def test_connections_from_object(self): 56 | tpl = """{%% spaceless %%} 57 | {%% load connections %%} 58 | {%% connections_from_object %s foo as connections %%} 59 | {%% for c in connections %%}{{ c.to_object.username }}, {%% endfor %%} 60 | {%% endspaceless %%}""" 61 | 62 | create_connection(self.r, self.foo, self.bar) 63 | create_connection(self.r, self.foo, self.jaz) 64 | 65 | assert 'bar, jaz,' == Template(tpl % "'user_follow'").render(Context({ 66 | 'foo': self.foo, 67 | })) 68 | 69 | assert 'bar, jaz,' == Template(tpl % "rel").render(Context({ 70 | 'rel': self.r, 71 | 'foo': self.foo, 72 | })) 73 | 74 | def test_connections_to_object(self): 75 | tpl = """{%% spaceless %%} 76 | {%% load connections %%} 77 | {%% connections_to_object %s foo as connections %%} 78 | {%% for c in connections %%}{{ c.from_object.username }}, {%% endfor %%} 79 | {%% endspaceless %%}""" 80 | 81 | create_connection(self.r, self.bar, self.foo) 82 | create_connection(self.r, self.jaz, self.foo) 83 | 84 | assert 'bar, jaz,' == Template(tpl % "'user_follow'").render(Context({ 85 | 'foo': self.foo, 86 | })) 87 | 88 | assert 'bar, jaz,' == Template(tpl % "rel").render(Context({ 89 | 'rel': self.r, 90 | 'foo': self.foo, 91 | })) 92 | 93 | def test_connection_exists(self): 94 | tpl = """{%% spaceless %%} 95 | {%% load connections %%} 96 | {%% connection_exists %s foo bar as has_connection %%} 97 | {{ has_connection|yesno:'True,False' }} 98 | {%% endspaceless %%}""" 99 | 100 | create_connection(self.r, self.foo, self.bar) 101 | 102 | assert 'True' == Template(tpl % "'user_follow'").render(Context({ 103 | 'foo': self.foo, 104 | 'bar': self.bar, 105 | })) 106 | 107 | assert 'True' == Template(tpl % "rel").render(Context({ 108 | 'rel': self.r, 109 | 'foo': self.foo, 110 | 'bar': self.bar, 111 | })) 112 | -------------------------------------------------------------------------------- /tests/test_connections.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.contrib.auth.models import User, Group 4 | from django.test import TestCase 5 | 6 | from connections.models import Connection, _relationship_registry as registry 7 | from connections.shortcuts import (define_relationship, get_relationship, 8 | create_connection, get_connection, connection_exists, 9 | connections_from_object, connections_to_object, 10 | connected_objects, connected_to_objects) 11 | 12 | 13 | def reset_registry(d): 14 | for k in list(d.keys()): 15 | d.pop(k) 16 | 17 | 18 | def reset_relationship(r): 19 | Connection.objects.filter(relationship_name=get_relationship(r).name).delete() 20 | 21 | 22 | class ConnectionTests(TestCase): 23 | def setUp(self): 24 | reset_registry(registry) 25 | self.r = define_relationship('user_follow', User, User) 26 | self.foo = User.objects.create_user(username='foo') 27 | self.bar = User.objects.create_user(username='bar') 28 | self.jaz = User.objects.create_user(username='jaz') 29 | reset_relationship('user_follow') 30 | 31 | def tearDown(self): 32 | reset_relationship('user_follow') 33 | reset_registry(registry) 34 | self.foo.delete() 35 | self.bar.delete() 36 | self.jaz.delete() 37 | 38 | def test_validate_ctypes(self): 39 | group = Group.objects.create(name='testgroup') 40 | try: 41 | self.r._validate_ctypes(self.foo, self.bar) 42 | self.assertRaises(AssertionError, self.r._validate_ctypes, group, self.bar) 43 | self.assertRaises(AssertionError, self.r._validate_ctypes, self.bar, group) 44 | finally: 45 | group.delete() 46 | 47 | def test_create_connection(self): 48 | c = create_connection(self.r, self.foo, self.bar) 49 | assert c.relationship_name == self.r.name 50 | assert c.relationship is self.r 51 | assert c.from_object == self.foo 52 | assert c.to_object == self.bar 53 | assert re.match(r'user_follow \(user:\d+ --> user:\d+\)', str(c)) 54 | 55 | def test_get_connection(self): 56 | c = create_connection(self.r, self.foo, self.bar) 57 | assert get_connection(self.r, self.foo, self.bar) == c 58 | assert get_connection(self.r, self.foo, self.jaz) is None 59 | 60 | def test_connection_exists(self): 61 | assert not connection_exists(self.r, self.foo, self.bar) 62 | create_connection(self.r, self.foo, self.bar) 63 | assert connection_exists(self.r, self.foo, self.bar) 64 | 65 | def test_connections_from_object(self): 66 | c1 = create_connection(self.r, self.foo, self.bar) 67 | c2 = create_connection(self.r, self.foo, self.jaz) 68 | assert set(connections_from_object(self.r, self.foo)) == set([c1, c2]) 69 | 70 | def test_connections_to_object(self): 71 | c1 = create_connection(self.r, self.bar, self.foo) 72 | c2 = create_connection(self.r, self.jaz, self.foo) 73 | assert set(connections_to_object(self.r, self.foo)) == set([c1, c2]) 74 | 75 | def test_connected_objects(self): 76 | create_connection(self.r, self.foo, self.bar) 77 | create_connection(self.r, self.foo, self.jaz) 78 | assert set(connected_objects(self.r, self.foo)) == set([self.bar, self.jaz]) 79 | 80 | def test_connected_to_objects(self): 81 | create_connection(self.r, self.bar, self.foo) 82 | create_connection(self.r, self.jaz, self.foo) 83 | assert set(connected_to_objects(self.r, self.foo)) == set([self.bar, self.jaz]) 84 | 85 | def test_connected_object_ids(self): 86 | create_connection(self.r, self.foo, self.bar) 87 | create_connection(self.r, self.foo, self.jaz) 88 | assert set(self.r.connected_object_ids(self.foo)) == set([self.bar.pk, self.jaz.pk]) 89 | 90 | def test_connected_to_object_ids(self): 91 | create_connection(self.r, self.bar, self.foo) 92 | create_connection(self.r, self.jaz, self.foo) 93 | assert set(self.r.connected_to_object_ids(self.foo)) == set([self.bar.pk, self.jaz.pk]) 94 | 95 | def test_distance_between(self): 96 | create_connection(self.r, self.foo, self.foo) 97 | create_connection(self.r, self.foo, self.bar) 98 | create_connection(self.r, self.bar, self.jaz) 99 | assert self.r.distance_between(self.foo, self.foo) == 0 100 | assert self.r.distance_between(self.foo, self.bar) == 1 101 | assert self.r.distance_between(self.foo, self.jaz) == 2 102 | assert self.r.distance_between(self.bar, self.jaz) == 1 103 | assert self.r.distance_between(self.bar, self.foo) is None 104 | assert self.r.distance_between(self.jaz, self.foo) is None 105 | assert self.r.distance_between(self.jaz, self.bar) is None 106 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-connections 2 | ^^^^^^^^^^^^^^^^^^ 3 | 4 | .. image:: https://travis-ci.org/dfunckt/django-connections.svg?branch=master 5 | :target: https://travis-ci.org/dfunckt/django-connections 6 | .. image:: https://coveralls.io/repos/dfunckt/django-connections/badge.png?branch=master 7 | :target: https://coveralls.io/r/dfunckt/django-connections?branch=master 8 | 9 | ``connections`` is a small app for Django that allows you to model any kind of 10 | relationship between instances of *any* model. It's primary use is for 11 | building a social graph that you can query and manage easily. 12 | 13 | 14 | Requirements 15 | ============ 16 | 17 | ``connections`` requires Python 2.6/3.2 or newer and Django 1.5 or newer. 18 | 19 | 20 | How to install 21 | ============== 22 | 23 | Using pip:: 24 | 25 | $ pip install django-connections 26 | 27 | Manually:: 28 | 29 | $ git clone https://github.com/dfunckt/django-connections.git 30 | $ cd django-connections 31 | $ python setup.py install 32 | 33 | 34 | Configuration 35 | ============= 36 | 37 | Add ``django.contrib.contenttypes`` and ``connections`` to your settings:: 38 | 39 | INSTALLED_APPS = ( 40 | # ... 41 | 'django.contrib.contenttypes', 42 | 'connections', 43 | ) 44 | 45 | Order does not matter. Then, run ``migrate``:: 46 | 47 | $ python manage.py migrate 48 | 49 | **Note**: If you're running Django 1.6 or lower, you should run 50 | ``manage.py syncdb`` instead. 51 | 52 | 53 | Using ``connections`` 54 | ===================== 55 | 56 | With ``connections`` you essentially build `directed graphs`_, where each 57 | *node* is a model instance and each *edge* is a ``Connection`` instance. Which 58 | two models a connection can connect, is determined by a ``Relationship`` 59 | instance that you predefine. 60 | 61 | .. _directed graphs: http://wikipedia.org/wiki/Directed_graph 62 | 63 | 64 | Defining relationships 65 | ---------------------- 66 | 67 | Assume you're *LitHub*, a social coding site in its infancy, and you need to 68 | let your users star repositories they find interesting. With ``connections``, 69 | you would first define a relationship:: 70 | 71 | >>> from django.contrib.auth.models import User 72 | >>> from connections import define_relationship 73 | >>> from lithub.models import Repo 74 | >>> repo_stars = define_relationship('star_repo', from_model=User, to_model=Repo) 75 | 76 | ``define_relationship`` creates and registers a new ``Relationship`` instance 77 | between the given models, with the name ``'star_repo'``. Names of 78 | relationships must be unique across your project. You may alternatively 79 | specify the models of the relationship as strings, e.g. ``'auth.User'`` or 80 | ``'lithub.Repo'``. 81 | 82 | Any time you need to reference a relationship, you can either import the 83 | module variable (as defined above), or use ``connections.get_relationship(name)``. 84 | 85 | 86 | Managing connections 87 | -------------------- 88 | 89 | Let's say that ``milo`` found a nice Python project on LitHub that he'd like 90 | to star, for future reference. In ``connections`` this can be modelled by 91 | creating a connection between ``milo`` and the repository instance:: 92 | 93 | >>> milo = User.objects.get(pk=104) 94 | >>> foopy = Repo.objects.get(pk=47) 95 | >>> star_repo.create_connection(milo, foopy) 96 | 'star_repo (auth.User:104 -> lithub.Repo:47)' 97 | 98 | Connections are unidirectional, meaning that if *foo* is connected with 99 | *bar*, the reverse -- that *bar* is connected to *foo* -- is *not* implied. 100 | If you'd like to model a symmetrical relationship, that is, one that only 101 | makes sense if both sides have agreed in the relationship (e.g. *friendship* 102 | or even *marriage*), you'd have to create two opposite connections, one for 103 | each side of the relationship. 104 | 105 | Let's see what repositories ``milo`` has starred:: 106 | 107 | >>> repo_stars.connected_objects(milo) 108 | [] 109 | 110 | We can also query for the reverse, that is, what users have starred ``foopy``:: 111 | 112 | >>> repo_stars.connected_to_objects(foopy) 113 | [] 114 | 115 | There are several other methods you may use to query or manage connections, 116 | that you may read about in `API Reference`_. 117 | 118 | 119 | Best practices 120 | ============== 121 | 122 | The preferred idiom is to define relationships in ``app/relationships.py`` 123 | files, keeping a module-global reference to each relationship instance, 124 | through which you manage connections between your model instances. 125 | 126 | If you're using Django 1.7 or later you may have any ``relationships.py`` 127 | modules automatically imported at start-up:: 128 | 129 | INSTALLED_APPS = ( 130 | # ... 131 | 'connections.apps.AutodiscoverConnectionsConfig', 132 | ) 133 | 134 | 135 | API Reference 136 | ============= 137 | 138 | 139 | Class ``Relationship`` 140 | ---------------------- 141 | 142 | Represents a predefined type of connection between two nodes in a (directed) 143 | graph. You may imagine relationships as the *kind* of an edge in the graph. 144 | :: 145 | 146 | >>> from connections.models import Relationship 147 | >>> rel = Relationship('rel_name', from_content_type, to_content_type) 148 | 149 | 150 | Instance properties 151 | +++++++++++++++++++ 152 | 153 | ``connections`` 154 | Returns a ``Connection`` query set matching all connections of this 155 | relationship. 156 | 157 | 158 | Instance methods 159 | ++++++++++++++++ 160 | 161 | ``create_connection(from_obj, to_obj)`` 162 | Creates and returns a new ``Connection`` instance between the given 163 | objects. If a connection already exists, the existing connection will be 164 | returned instead of creating a new one. 165 | 166 | ``get_connection(from_obj, to_obj)`` 167 | Returns a ``Connection`` instance for the given objects or ``None`` if 168 | there's no connection. 169 | 170 | ``connection_exists(from_obj, to_obj)`` 171 | Returns ``True`` if a connection between the given objects exists, 172 | else ``False``. 173 | 174 | ``connections_from_object(from_obj)`` 175 | Returns a ``Connection`` query set matching all connections with 176 | the given object as a source. 177 | 178 | ``connections_to_object(to_obj)`` 179 | Returns a ``Connection`` query set matching all connections with 180 | the given object as a destination. 181 | 182 | ``connected_objects(from_obj)`` 183 | Returns a query set matching all connected objects with the given 184 | object as a source. 185 | 186 | ``connected_object_ids(from_obj)`` 187 | Returns an iterable of the IDs of all objects connected with the given 188 | object as a source (i.e. the ``Connection.to_pk`` values). 189 | 190 | ``connected_to_objects(to_obj)`` 191 | Returns a query set matching all connected objects with the given 192 | object as a destination. 193 | 194 | ``connected_to_object_ids(to_obj)`` 195 | Returns an iterable of the IDs of all objects connected with the given 196 | object as a destination (i.e. the ``Connection.from_pk`` values). 197 | 198 | ``distance_between(from_obj, to_obj, limit=2)`` 199 | Calculates and returns an integer for the distance between two objects. 200 | A distance of *0* means ``from_obj`` and ``to_obj`` are the same 201 | objects, *1* means ``from_obj`` has a direct connection to ``to_obj``, 202 | *2* means that one or more of ``from_obj``'s connected objects are 203 | directly connected to ``to_obj``, and so on. ``limit`` limits the depth of 204 | connections traversal. Returns ``None`` if the two objects are not 205 | connected within ``limit`` distance. 206 | 207 | 208 | Class ``Connection`` 209 | -------------------- 210 | 211 | Represents a connection between two nodes in the graph. Connections must 212 | be treated as unidirectional, i.e. creating a connection from one node to 213 | another should not imply the reverse. 214 | 215 | 216 | Model attributes 217 | ++++++++++++++++ 218 | 219 | ``relationship_name`` 220 | The name of the relationship. To access the relationship instance, use the 221 | ``Connection.relationship`` property. 222 | 223 | ``from_pk`` 224 | The primary key of the instance acting as source. 225 | 226 | ``to_pk`` 227 | The primary key of the instance acting as destination. 228 | 229 | ``date`` 230 | A ``datetime`` instance of the time the connection was created. 231 | 232 | 233 | Instance properties 234 | +++++++++++++++++++ 235 | 236 | ``relationship`` 237 | Returns the ``Relationship`` instance the connection is about. 238 | 239 | ``from_object`` 240 | The source instance. 241 | 242 | ``to_object`` 243 | The destination instance. 244 | -------------------------------------------------------------------------------- /connections/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | from django.core.exceptions import ObjectDoesNotExist 3 | from django.db import models 4 | from django.db.models.signals import post_save, post_delete 5 | from django.utils import timezone 6 | 7 | try: 8 | from django.apps import apps as loading 9 | except ImportError: # pragma: no cover 10 | # Django < 1.7 11 | from django.db.models import loading 12 | 13 | from .signals import connection_created, connection_removed 14 | 15 | 16 | NAME_MAX_LENGTH = 50 17 | 18 | 19 | def get_model(model): 20 | """ 21 | Given a model name as ``app_label.ModelName``, returns the Django model. 22 | """ 23 | try: 24 | if isinstance(model, str): 25 | app_label, model_name = model.split('.', 1) 26 | m = loading.get_model(app_label, model_name) 27 | if not m: # pragma: no cover 28 | raise LookupError() # Django < 1.7 just returns None 29 | return m 30 | elif issubclass(model, models.Model): 31 | return model 32 | except (LookupError, ValueError): 33 | pass 34 | raise ValueError(model) 35 | 36 | 37 | _relationship_registry = {} 38 | 39 | 40 | def define_relationship(name, from_model, to_model): 41 | if name in _relationship_registry: 42 | raise KeyError(name) 43 | 44 | _from_ctype = from_model 45 | _to_ctype = to_model 46 | 47 | if isinstance(_from_ctype, str): 48 | _from_ctype = get_model(_from_ctype) 49 | if isinstance(_to_ctype, str): 50 | _to_ctype = get_model(_to_ctype) 51 | 52 | if not isinstance(_from_ctype, ContentType): 53 | _from_ctype = ContentType.objects.get_for_model(_from_ctype) 54 | if not isinstance(_to_ctype, ContentType): 55 | _to_ctype = ContentType.objects.get_for_model(_to_ctype) 56 | 57 | relationship = Relationship(name=name, 58 | from_content_type=_from_ctype, 59 | to_content_type=_to_ctype) 60 | 61 | _relationship_registry[name] = relationship 62 | return relationship 63 | 64 | 65 | def get_relationship(name): 66 | if isinstance(name, Relationship): 67 | return name 68 | if name not in _relationship_registry: 69 | raise Relationship.DoesNotExist(name) 70 | return _relationship_registry[name] 71 | 72 | 73 | class RelationshipDoesNotExist(ObjectDoesNotExist): 74 | """ 75 | Exception thrown when a relationship is not found in the registry. 76 | """ 77 | 78 | 79 | class Relationship(object): 80 | """ 81 | Represents a predefined type of connection between two nodes in a 82 | (directed) graph. You may imagine relationships as the *"flavour"* 83 | of an edge in the graph. 84 | """ 85 | DoesNotExist = RelationshipDoesNotExist 86 | 87 | def __init__(self, name, from_content_type, to_content_type): 88 | assert len(name) <= NAME_MAX_LENGTH 89 | assert isinstance(from_content_type, ContentType) 90 | assert isinstance(to_content_type, ContentType) 91 | self.name = name 92 | self.from_content_type = from_content_type 93 | self.to_content_type = to_content_type 94 | 95 | def __str__(self): 96 | return '%s (%s -> %s)' % (self.name, self.from_content_type, 97 | self.to_content_type) 98 | 99 | def _validate_ctypes(self, from_obj, to_obj): 100 | """ 101 | Asserts that the content types for the given object are valid for this 102 | relationship. If validation fails, ``AssertionError`` will be raised. 103 | """ 104 | if from_obj: 105 | from_ctype = ContentType.objects.get_for_model(from_obj) 106 | assert from_ctype.natural_key() == self.from_content_type.natural_key(), ( 107 | 'Relationship "%s" does not support connections ' 108 | 'from "%s" types' % (self.name, from_ctype)) 109 | if to_obj: 110 | to_ctype = ContentType.objects.get_for_model(to_obj) 111 | assert to_ctype.natural_key() == self.to_content_type.natural_key(), ( 112 | 'Relationship "%s" does not support connections ' 113 | 'to "%s" types' % (self.name, to_ctype)) 114 | 115 | @property 116 | def connections(self): 117 | """ 118 | Returns a query set matching all connections of this relationship. 119 | """ 120 | return Connection.objects.filter(relationship_name=self.name) 121 | 122 | def create_connection(self, from_obj, to_obj): 123 | """ 124 | Creates and returns a connection between the given objects. If a 125 | connection already exists, that connection will be returned instead. 126 | """ 127 | self._validate_ctypes(from_obj, to_obj) 128 | return Connection.objects.get_or_create(relationship_name=self.name, 129 | from_pk=from_obj.pk, to_pk=to_obj.pk)[0] 130 | 131 | def get_connection(self, from_obj, to_obj): 132 | """ 133 | Returns a ``Connection`` instance for the given objects or ``None`` if 134 | there's no connection. 135 | """ 136 | self._validate_ctypes(from_obj, to_obj) 137 | try: 138 | return self.connections.get(from_pk=from_obj.pk, to_pk=to_obj.pk) 139 | except Connection.DoesNotExist: 140 | return None 141 | 142 | def connection_exists(self, from_obj, to_obj): 143 | """ 144 | Returns ``True`` if a connection between the given objects exists, 145 | else ``False``. 146 | """ 147 | self._validate_ctypes(from_obj, to_obj) 148 | return self.connections.filter(from_pk=from_obj.pk, to_pk=to_obj.pk).exists() 149 | 150 | def connections_from_object(self, from_obj): 151 | """ 152 | Returns a ``Connection`` query set matching all connections with 153 | the given object as a source. 154 | """ 155 | self._validate_ctypes(from_obj, None) 156 | return self.connections.filter(from_pk=from_obj.pk) 157 | 158 | def connections_to_object(self, to_obj): 159 | """ 160 | Returns a ``Connection`` query set matching all connections with 161 | the given object as a destination. 162 | """ 163 | self._validate_ctypes(None, to_obj) 164 | return self.connections.filter(to_pk=to_obj.pk) 165 | 166 | def connected_objects(self, from_obj): 167 | """ 168 | Returns a query set matching all connected objects with the given 169 | object as a source. 170 | """ 171 | return self.to_content_type.get_all_objects_for_this_type(pk__in=self.connected_object_ids(from_obj)) 172 | 173 | def connected_object_ids(self, from_obj): 174 | """ 175 | Returns an iterable of the IDs of all objects connected with the given 176 | object as a source (ie. the Connection.to_pk values). 177 | """ 178 | return self.connections_from_object(from_obj).values_list('to_pk', flat=True) 179 | 180 | def connected_to_objects(self, to_obj): 181 | """ 182 | Returns a query set matching all connected objects with the given 183 | object as a destination. 184 | """ 185 | return self.from_content_type.get_all_objects_for_this_type(pk__in=self.connected_to_object_ids(to_obj)) 186 | 187 | def connected_to_object_ids(self, to_obj): 188 | """ 189 | Returns an iterable of the IDs of all objects connected with the given 190 | object as a destination (ie. the Connection.from_pk values). 191 | """ 192 | return self.connections_to_object(to_obj).values_list('from_pk', flat=True) 193 | 194 | def distance_between(self, from_obj, to_obj, limit=2): 195 | """ 196 | Calculates the distance between two objects. Distance 0 means 197 | ``from_obj`` and ``to_obj`` are the same objects, 1 means ``from_obj`` 198 | has a direct connection to ``to_obj``, 2 means that one or more of 199 | ``from_obj``'s connected objects are directly connected to ``to_obj``, 200 | etc. 201 | 202 | ``limit`` limits the depth of connections traversal. 203 | 204 | Returns ``None`` if the two objects are not connected within ``limit`` 205 | distance. 206 | """ 207 | self._validate_ctypes(from_obj, to_obj) 208 | 209 | if from_obj == to_obj: 210 | return 0 211 | 212 | d = 1 213 | pk = to_obj.pk 214 | qs = self.connections 215 | pks = qs.filter(from_pk=from_obj.pk).values_list('to_pk', flat=True) 216 | while limit > 0: 217 | if pk in pks: 218 | return d 219 | else: 220 | pks = qs.filter(from_pk__in=pks).values_list('pk', flat=True) 221 | d += 1 222 | limit -= 1 223 | 224 | return None 225 | 226 | 227 | class Connection(models.Model): 228 | """ 229 | Represents a connection between two nodes in a graph. Connections must 230 | be treated as non-symmetrical (unidirectional), i.e. creating a connection 231 | from one node to another should not imply the reverse. 232 | 233 | You may imagine connections as the *edges* in a graph. 234 | """ 235 | relationship_name = models.CharField(max_length=NAME_MAX_LENGTH) 236 | from_pk = models.IntegerField() 237 | to_pk = models.IntegerField() 238 | weight = models.FloatField(default=1.0, blank=True) 239 | date = models.DateTimeField(default=timezone.now) 240 | 241 | class Meta: 242 | unique_together = ('relationship_name', 'from_pk', 'to_pk') 243 | 244 | def __str__(self): 245 | rel = self.relationship 246 | return '%s (%s:%s --> %s:%s)' % (rel.name, 247 | rel.from_content_type, self.from_pk, 248 | rel.to_content_type, self.to_pk) 249 | 250 | @property 251 | def relationship(self): 252 | return get_relationship(self.relationship_name) 253 | 254 | @property 255 | def from_object(self): 256 | if not hasattr(self, '_cached_from_obj'): 257 | ct = self.relationship.from_content_type 258 | self._cached_from_obj = ct.get_object_for_this_type(pk=self.from_pk) 259 | return self._cached_from_obj 260 | 261 | @property 262 | def to_object(self): 263 | if not hasattr(self, '_cached_to_obj'): 264 | ct = self.relationship.to_content_type 265 | self._cached_to_obj = ct.get_object_for_this_type(pk=self.to_pk) 266 | return self._cached_to_obj 267 | 268 | 269 | def _connection_created_handler(sender, instance, raw, created, **kwargs): 270 | if not raw and created: 271 | connection_created.send(sender=instance.relationship, connection=instance) 272 | post_save.connect(_connection_created_handler, sender=Connection) 273 | 274 | 275 | def _connection_removed_handler(sender, instance, **kwargs): 276 | connection_removed.send(sender=instance.relationship, connection=instance) 277 | post_delete.connect(_connection_removed_handler, sender=Connection) 278 | --------------------------------------------------------------------------------