├── .gitignore ├── .travis.yml ├── MANIFEST.in ├── README.rst ├── __init__.py ├── ez_setup.py ├── followit ├── LICENSE ├── TODO.rst ├── __init__.py ├── compat.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── tests │ ├── README.rst │ ├── __init__.py │ ├── manage.py │ ├── models.py │ ├── runtests.py │ ├── settings.py │ └── tests.py ├── urls.py ├── utils.py └── views.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | env/ 3 | dist/ 4 | build/ 5 | django_followit.egg-info/ 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - 2.6 5 | - 2.7 6 | - 3.4 7 | 8 | env: 9 | - DJANGO_VERSION=1.7.10 10 | 11 | install: 12 | - pip install -q Django==$DJANGO_VERSION 13 | 14 | script: 15 | - cd followit/tests/ && python runtests.py 16 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include ez_setup.py 2 | include tox.ini 3 | recursive-include followit * 4 | recursive-exclude followit *.pyc 5 | recursive-exclude .git 6 | prune dist 7 | prune build 8 | global exclude *.pyc 9 | exclude settings.py 10 | exclude manage.py 11 | exclude __init__.py 12 | exclude urls.py 13 | recursive-exclude env 14 | recursive-exclude .tox 15 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://travis-ci.org/vinodpandey/django-followit.png?branch=master 2 | :alt: Build Status 3 | :align: left 4 | 5 | The ``followit`` django app allows for the site users 6 | to follow various instances of Django models, 7 | represented by django model ``followit.models.FollowRecord`` 8 | using the ``django.contrib.models.ContentTypes`` system. 9 | 10 | Release Notes 11 | ============= 12 | 13 | The list below shows compatibility of `django-followit` with versions of Django and Python. 14 | Python version compatibility was thoroughly tested only with release `0.4.0`:: 15 | 16 | * ``0.4.x`` supports django versions from 1.7(**) up to 3. Python 2 and 3. 17 | * ``0.3.x`` - django 1.9 - 1.11 18 | * ``0.2.x`` - django 1.8 19 | * ``0.1.x`` - django 1.7 20 | * ``0.0.9`` can be used for the earlier versions 21 | 22 | (**) versions ``0.4.x`` do not support Django 1.7 with Python 3. 23 | 24 | Setup 25 | ===== 26 | 27 | To the INSTALLED_APPS in your ``settings.py`` add entry ``'followit'``. 28 | 29 | Run `python manage.py migrate followit` 30 | 31 | Then, in the body of `AppConfig.ready` method, add:: 32 | 33 | import followit 34 | followit.register(Thing) 35 | 36 | Not it will be possible for the user to follow instances of ``SomeModel``. 37 | 38 | If you decide to allow following another model, just add another 39 | ``followit.register(...)`` statement. 40 | 41 | Usage 42 | ===== 43 | 44 | Examples below show how to use ``followit``:: 45 | 46 | bob.follow_thing(x) 47 | bob.unfollow_thing(x) 48 | things = bob.get_followed_things() 49 | x_followers = x.get_followers() 50 | 51 | To follow/unfollow items via the HTTTP, make AJAX post requests at urls, 52 | available urls ``followit/urls.py``:: 53 | 54 | /follow/// 55 | {% url follow_object "somemodel" item_id %} #model name lower case 56 | 57 | /unfollow/// 58 | {% url unfollow_object "somemodel" item_id %} #lower case model name 59 | 60 | /toggle-follow/// 61 | {% url toggle_follow_object "somemodel" item_id %} #lower case model name 62 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ASKBOT/django-followit/e62089d42c4f4a5b5f230da50f5d64d55362e2fd/__init__.py -------------------------------------------------------------------------------- /ez_setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Setuptools bootstrapping installer. 5 | 6 | Maintained at https://github.com/pypa/setuptools/tree/bootstrap. 7 | 8 | Run this script to install or upgrade setuptools. 9 | 10 | This method is DEPRECATED. Check https://github.com/pypa/setuptools/issues/581 for more details. 11 | """ 12 | 13 | import os 14 | import shutil 15 | import sys 16 | import tempfile 17 | import zipfile 18 | import optparse 19 | import subprocess 20 | import platform 21 | import textwrap 22 | import contextlib 23 | 24 | from distutils import log 25 | 26 | try: 27 | from urllib.request import urlopen 28 | except ImportError: 29 | from urllib2 import urlopen 30 | 31 | try: 32 | from site import USER_SITE 33 | except ImportError: 34 | USER_SITE = None 35 | 36 | # 33.1.1 is the last version that supports setuptools self upgrade/installation. 37 | DEFAULT_VERSION = "33.1.1" 38 | DEFAULT_URL = "https://pypi.io/packages/source/s/setuptools/" 39 | DEFAULT_SAVE_DIR = os.curdir 40 | DEFAULT_DEPRECATION_MESSAGE = "ez_setup.py is deprecated and when using it setuptools will be pinned to {0} since it's the last version that supports setuptools self upgrade/installation, check https://github.com/pypa/setuptools/issues/581 for more info; use pip to install setuptools" 41 | 42 | MEANINGFUL_INVALID_ZIP_ERR_MSG = 'Maybe {0} is corrupted, delete it and try again.' 43 | 44 | log.warn(DEFAULT_DEPRECATION_MESSAGE.format(DEFAULT_VERSION)) 45 | 46 | 47 | def _python_cmd(*args): 48 | """ 49 | Execute a command. 50 | 51 | Return True if the command succeeded. 52 | """ 53 | args = (sys.executable,) + args 54 | return subprocess.call(args) == 0 55 | 56 | 57 | def _install(archive_filename, install_args=()): 58 | """Install Setuptools.""" 59 | with archive_context(archive_filename): 60 | # installing 61 | log.warn('Installing Setuptools') 62 | if not _python_cmd('setup.py', 'install', *install_args): 63 | log.warn('Something went wrong during the installation.') 64 | log.warn('See the error message above.') 65 | # exitcode will be 2 66 | return 2 67 | 68 | 69 | def _build_egg(egg, archive_filename, to_dir): 70 | """Build Setuptools egg.""" 71 | with archive_context(archive_filename): 72 | # building an egg 73 | log.warn('Building a Setuptools egg in %s', to_dir) 74 | _python_cmd('setup.py', '-q', 'bdist_egg', '--dist-dir', to_dir) 75 | # returning the result 76 | log.warn(egg) 77 | if not os.path.exists(egg): 78 | raise IOError('Could not build the egg.') 79 | 80 | 81 | class ContextualZipFile(zipfile.ZipFile): 82 | 83 | """Supplement ZipFile class to support context manager for Python 2.6.""" 84 | 85 | def __enter__(self): 86 | return self 87 | 88 | def __exit__(self, type, value, traceback): 89 | self.close() 90 | 91 | def __new__(cls, *args, **kwargs): 92 | """Construct a ZipFile or ContextualZipFile as appropriate.""" 93 | if hasattr(zipfile.ZipFile, '__exit__'): 94 | return zipfile.ZipFile(*args, **kwargs) 95 | return super(ContextualZipFile, cls).__new__(cls) 96 | 97 | 98 | @contextlib.contextmanager 99 | def archive_context(filename): 100 | """ 101 | Unzip filename to a temporary directory, set to the cwd. 102 | 103 | The unzipped target is cleaned up after. 104 | """ 105 | tmpdir = tempfile.mkdtemp() 106 | log.warn('Extracting in %s', tmpdir) 107 | old_wd = os.getcwd() 108 | try: 109 | os.chdir(tmpdir) 110 | try: 111 | with ContextualZipFile(filename) as archive: 112 | archive.extractall() 113 | except zipfile.BadZipfile as err: 114 | if not err.args: 115 | err.args = ('', ) 116 | err.args = err.args + ( 117 | MEANINGFUL_INVALID_ZIP_ERR_MSG.format(filename), 118 | ) 119 | raise 120 | 121 | # going in the directory 122 | subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) 123 | os.chdir(subdir) 124 | log.warn('Now working in %s', subdir) 125 | yield 126 | 127 | finally: 128 | os.chdir(old_wd) 129 | shutil.rmtree(tmpdir) 130 | 131 | 132 | def _do_download(version, download_base, to_dir, download_delay): 133 | """Download Setuptools.""" 134 | py_desig = 'py{sys.version_info[0]}.{sys.version_info[1]}'.format(sys=sys) 135 | tp = 'setuptools-{version}-{py_desig}.egg' 136 | egg = os.path.join(to_dir, tp.format(**locals())) 137 | if not os.path.exists(egg): 138 | archive = download_setuptools(version, download_base, 139 | to_dir, download_delay) 140 | _build_egg(egg, archive, to_dir) 141 | sys.path.insert(0, egg) 142 | 143 | # Remove previously-imported pkg_resources if present (see 144 | # https://bitbucket.org/pypa/setuptools/pull-request/7/ for details). 145 | if 'pkg_resources' in sys.modules: 146 | _unload_pkg_resources() 147 | 148 | import setuptools 149 | setuptools.bootstrap_install_from = egg 150 | 151 | 152 | def use_setuptools( 153 | version=DEFAULT_VERSION, download_base=DEFAULT_URL, 154 | to_dir=DEFAULT_SAVE_DIR, download_delay=15): 155 | """ 156 | Ensure that a setuptools version is installed. 157 | 158 | Return None. Raise SystemExit if the requested version 159 | or later cannot be installed. 160 | """ 161 | to_dir = os.path.abspath(to_dir) 162 | 163 | # prior to importing, capture the module state for 164 | # representative modules. 165 | rep_modules = 'pkg_resources', 'setuptools' 166 | imported = set(sys.modules).intersection(rep_modules) 167 | 168 | try: 169 | import pkg_resources 170 | pkg_resources.require("setuptools>=" + version) 171 | # a suitable version is already installed 172 | return 173 | except ImportError: 174 | # pkg_resources not available; setuptools is not installed; download 175 | pass 176 | except pkg_resources.DistributionNotFound: 177 | # no version of setuptools was found; allow download 178 | pass 179 | except pkg_resources.VersionConflict as VC_err: 180 | if imported: 181 | _conflict_bail(VC_err, version) 182 | 183 | # otherwise, unload pkg_resources to allow the downloaded version to 184 | # take precedence. 185 | del pkg_resources 186 | _unload_pkg_resources() 187 | 188 | return _do_download(version, download_base, to_dir, download_delay) 189 | 190 | 191 | def _conflict_bail(VC_err, version): 192 | """ 193 | Setuptools was imported prior to invocation, so it is 194 | unsafe to unload it. Bail out. 195 | """ 196 | conflict_tmpl = textwrap.dedent(""" 197 | The required version of setuptools (>={version}) is not available, 198 | and can't be installed while this script is running. Please 199 | install a more recent version first, using 200 | 'easy_install -U setuptools'. 201 | 202 | (Currently using {VC_err.args[0]!r}) 203 | """) 204 | msg = conflict_tmpl.format(**locals()) 205 | sys.stderr.write(msg) 206 | sys.exit(2) 207 | 208 | 209 | def _unload_pkg_resources(): 210 | sys.meta_path = [ 211 | importer 212 | for importer in sys.meta_path 213 | if importer.__class__.__module__ != 'pkg_resources.extern' 214 | ] 215 | del_modules = [ 216 | name for name in sys.modules 217 | if name.startswith('pkg_resources') 218 | ] 219 | for mod_name in del_modules: 220 | del sys.modules[mod_name] 221 | 222 | 223 | def _clean_check(cmd, target): 224 | """ 225 | Run the command to download target. 226 | 227 | If the command fails, clean up before re-raising the error. 228 | """ 229 | try: 230 | subprocess.check_call(cmd) 231 | except subprocess.CalledProcessError: 232 | if os.access(target, os.F_OK): 233 | os.unlink(target) 234 | raise 235 | 236 | 237 | def download_file_powershell(url, target): 238 | """ 239 | Download the file at url to target using Powershell. 240 | 241 | Powershell will validate trust. 242 | Raise an exception if the command cannot complete. 243 | """ 244 | target = os.path.abspath(target) 245 | ps_cmd = ( 246 | "[System.Net.WebRequest]::DefaultWebProxy.Credentials = " 247 | "[System.Net.CredentialCache]::DefaultCredentials; " 248 | '(new-object System.Net.WebClient).DownloadFile("%(url)s", "%(target)s")' 249 | % locals() 250 | ) 251 | cmd = [ 252 | 'powershell', 253 | '-Command', 254 | ps_cmd, 255 | ] 256 | _clean_check(cmd, target) 257 | 258 | 259 | def has_powershell(): 260 | """Determine if Powershell is available.""" 261 | if platform.system() != 'Windows': 262 | return False 263 | cmd = ['powershell', '-Command', 'echo test'] 264 | with open(os.path.devnull, 'wb') as devnull: 265 | try: 266 | subprocess.check_call(cmd, stdout=devnull, stderr=devnull) 267 | except Exception: 268 | return False 269 | return True 270 | download_file_powershell.viable = has_powershell 271 | 272 | 273 | def download_file_curl(url, target): 274 | cmd = ['curl', url, '--location', '--silent', '--output', target] 275 | _clean_check(cmd, target) 276 | 277 | 278 | def has_curl(): 279 | cmd = ['curl', '--version'] 280 | with open(os.path.devnull, 'wb') as devnull: 281 | try: 282 | subprocess.check_call(cmd, stdout=devnull, stderr=devnull) 283 | except Exception: 284 | return False 285 | return True 286 | download_file_curl.viable = has_curl 287 | 288 | 289 | def download_file_wget(url, target): 290 | cmd = ['wget', url, '--quiet', '--output-document', target] 291 | _clean_check(cmd, target) 292 | 293 | 294 | def has_wget(): 295 | cmd = ['wget', '--version'] 296 | with open(os.path.devnull, 'wb') as devnull: 297 | try: 298 | subprocess.check_call(cmd, stdout=devnull, stderr=devnull) 299 | except Exception: 300 | return False 301 | return True 302 | download_file_wget.viable = has_wget 303 | 304 | 305 | def download_file_insecure(url, target): 306 | """Use Python to download the file, without connection authentication.""" 307 | src = urlopen(url) 308 | try: 309 | # Read all the data in one block. 310 | data = src.read() 311 | finally: 312 | src.close() 313 | 314 | # Write all the data in one block to avoid creating a partial file. 315 | with open(target, "wb") as dst: 316 | dst.write(data) 317 | download_file_insecure.viable = lambda: True 318 | 319 | 320 | def get_best_downloader(): 321 | downloaders = ( 322 | download_file_powershell, 323 | download_file_curl, 324 | download_file_wget, 325 | download_file_insecure, 326 | ) 327 | viable_downloaders = (dl for dl in downloaders if dl.viable()) 328 | return next(viable_downloaders, None) 329 | 330 | 331 | def download_setuptools( 332 | version=DEFAULT_VERSION, download_base=DEFAULT_URL, 333 | to_dir=DEFAULT_SAVE_DIR, delay=15, 334 | downloader_factory=get_best_downloader): 335 | """ 336 | Download setuptools from a specified location and return its filename. 337 | 338 | `version` should be a valid setuptools version number that is available 339 | as an sdist for download under the `download_base` URL (which should end 340 | with a '/'). `to_dir` is the directory where the egg will be downloaded. 341 | `delay` is the number of seconds to pause before an actual download 342 | attempt. 343 | 344 | ``downloader_factory`` should be a function taking no arguments and 345 | returning a function for downloading a URL to a target. 346 | """ 347 | # making sure we use the absolute path 348 | to_dir = os.path.abspath(to_dir) 349 | zip_name = "setuptools-%s.zip" % version 350 | url = download_base + zip_name 351 | saveto = os.path.join(to_dir, zip_name) 352 | if not os.path.exists(saveto): # Avoid repeated downloads 353 | log.warn("Downloading %s", url) 354 | downloader = downloader_factory() 355 | downloader(url, saveto) 356 | return os.path.realpath(saveto) 357 | 358 | 359 | def _build_install_args(options): 360 | """ 361 | Build the arguments to 'python setup.py install' on the setuptools package. 362 | 363 | Returns list of command line arguments. 364 | """ 365 | return ['--user'] if options.user_install else [] 366 | 367 | 368 | def _parse_args(): 369 | """Parse the command line for options.""" 370 | parser = optparse.OptionParser() 371 | parser.add_option( 372 | '--user', dest='user_install', action='store_true', default=False, 373 | help='install in user site package') 374 | parser.add_option( 375 | '--download-base', dest='download_base', metavar="URL", 376 | default=DEFAULT_URL, 377 | help='alternative URL from where to download the setuptools package') 378 | parser.add_option( 379 | '--insecure', dest='downloader_factory', action='store_const', 380 | const=lambda: download_file_insecure, default=get_best_downloader, 381 | help='Use internal, non-validating downloader' 382 | ) 383 | parser.add_option( 384 | '--version', help="Specify which version to download", 385 | default=DEFAULT_VERSION, 386 | ) 387 | parser.add_option( 388 | '--to-dir', 389 | help="Directory to save (and re-use) package", 390 | default=DEFAULT_SAVE_DIR, 391 | ) 392 | options, args = parser.parse_args() 393 | # positional arguments are ignored 394 | return options 395 | 396 | 397 | def _download_args(options): 398 | """Return args for download_setuptools function from cmdline args.""" 399 | return dict( 400 | version=options.version, 401 | download_base=options.download_base, 402 | downloader_factory=options.downloader_factory, 403 | to_dir=options.to_dir, 404 | ) 405 | 406 | 407 | def main(): 408 | """Install or upgrade setuptools and EasyInstall.""" 409 | options = _parse_args() 410 | archive = download_setuptools(**_download_args(options)) 411 | return _install(archive, _build_install_args(options)) 412 | 413 | if __name__ == '__main__': 414 | sys.exit(main()) 415 | -------------------------------------------------------------------------------- /followit/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011, Evgeny Fadeev. 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 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above 11 | copyright notice, this list of conditions and the following 12 | disclaimer in the documentation and/or other materials provided 13 | with the distribution. 14 | * Neither the name of the author nor the names of other 15 | contributors may be used to endorse or promote products derived 16 | from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /followit/TODO.rst: -------------------------------------------------------------------------------- 1 | * create tests & test runner script 2 | * create doc pages 3 | * publish on pypi 4 | -------------------------------------------------------------------------------- /followit/__init__.py: -------------------------------------------------------------------------------- 1 | """A Django App allowing :class:`~django.contrib.auth.models.User` to 2 | follow instances of any other django models, including other users 3 | 4 | To use this module: 5 | * add "followit" to the ``INSTALLED_APPS`` in your ``settings.py`` 6 | * in your app's ``models.py`` add: 7 | 8 | import followit 9 | followit.register(Thing) 10 | 11 | * run ``python manage.py syncdb`` 12 | * then anywhere in your code you can do the following: 13 | 14 | user.follow(some_thing_instance) 15 | user.unfollow(some_thing_instance) 16 | 17 | user.get_followed_things() #note that "things" is from the name of class Thing 18 | some_thing.get_followers() 19 | 20 | Copyright 2011-2019 Evgeny Fadeev evgeny.fadeev@gmail.com 21 | The source code is available under BSD license. 22 | """ 23 | import django 24 | import sys 25 | from django.core.exceptions import ImproperlyConfigured 26 | 27 | if django.VERSION[:2] == (1, 7) and sys.version_info.major == 3: 28 | msg = """\n\nThis version of django-followit does not Django 1.7 with Python 3 29 | Either use Python 2.7 or upgrade Django to version from 1.8 up to 3. 30 | """ 31 | raise ImproperlyConfigured(msg) 32 | 33 | if django.VERSION < (1, 7) or django.VERSION >= (4, 0): 34 | msg = "\n\nThis version of django-followit supports Django 1.7 - 3." 35 | 36 | if django.VERSION < (1, 7): 37 | msg += "\nFor earlier Django versions try django-followit 0.0.9" 38 | 39 | raise ImproperlyConfigured(msg) 40 | 41 | from followit import utils 42 | from followit.compat import get_user_model 43 | 44 | REGISTRY = {} 45 | 46 | 47 | def get_model_name(model): 48 | try: 49 | return model._meta.module_name 50 | except AttributeError: 51 | return model._meta.model_name 52 | 53 | 54 | def get_follow_records(user, obj): 55 | from django.contrib.contenttypes.models import ContentType 56 | from followit.models import FollowRecord 57 | ct = ContentType.objects.get_for_model(obj) 58 | return FollowRecord.objects.filter( 59 | content_type=ct, 60 | object_id=obj.pk, 61 | user=user 62 | ) 63 | 64 | 65 | def get_object_followers(obj): 66 | """returns query set of users following the object""" 67 | from django.contrib.contenttypes.models import ContentType 68 | from followit.models import FollowRecord 69 | ct = ContentType.objects.get_for_model(obj) 70 | fr_set = FollowRecord.objects.filter(content_type=ct, object_id=obj.pk) 71 | uids = fr_set.values_list('user', flat=True) 72 | User = get_user_model() 73 | return User.objects.filter(pk__in=uids) 74 | 75 | 76 | def make_followed_objects_getter(model): 77 | """returns query set of objects of a class ``model`` 78 | that are followed by a user""" 79 | 80 | #something like followX_set__user 81 | def followed_objects_getter(user): 82 | from followit.models import FollowRecord 83 | from django.contrib.contenttypes.models import ContentType 84 | ct = ContentType.objects.get_for_model(model) 85 | fr_set = FollowRecord.objects.filter( 86 | content_type=ct, 87 | user=user 88 | ) 89 | obj_id_set = fr_set.values_list('object_id', flat=True) 90 | return model.objects.filter(pk__in=obj_id_set) 91 | 92 | 93 | return followed_objects_getter 94 | 95 | def test_follow_method(user, obj): 96 | """True if object ``obj`` is followed by the user, 97 | false otherwise, no error checking on whether the model 98 | has or has not been registered with the ``followit`` app 99 | """ 100 | fr_set = get_follow_records(user, obj) 101 | return fr_set.exists() 102 | 103 | 104 | def make_follow_method(model): 105 | """returns a method that adds a FollowX record 106 | for an object 107 | """ 108 | def follow_method(user, obj): 109 | """returns ``True`` if follow operation created a new record""" 110 | from followit.models import FollowRecord 111 | from django.contrib.contenttypes.models import ContentType 112 | ct = ContentType.objects.get_for_model(obj) 113 | fr, created = FollowRecord.objects.get_or_create( 114 | content_type=ct, 115 | object_id=obj.pk, 116 | user=user 117 | ) 118 | return created 119 | return follow_method 120 | 121 | 122 | def make_unfollow_method(model): 123 | """returns a method that allows to unfollow an item 124 | """ 125 | def unfollow_method(user, obj): 126 | """attempts to find an item and delete it, no 127 | exstence checking 128 | """ 129 | fr_set = get_follow_records(user, obj) 130 | fr_set.delete() 131 | return unfollow_method 132 | 133 | 134 | def register(model): 135 | """returns model class that connects 136 | User with the followed object 137 | 138 | ``model`` - is the model class to follow 139 | 140 | The ``model`` class gets new method - ``get_followers`` 141 | and the User class - a method - ``get_followed_Xs``, where 142 | the ``X`` is the name of the model 143 | and ``is_following(something)`` 144 | 145 | Note, that proper pluralization of the model name is not supported, 146 | just "s" is added 147 | """ 148 | from followit import models as followit_models 149 | from django.db import models as django_models 150 | from django.db.models.fields.related import ForeignKey 151 | 152 | User = get_user_model() 153 | 154 | model_name = get_model_name(model) 155 | if model in REGISTRY: 156 | return 157 | REGISTRY[model_name] = model 158 | 159 | #1) patch ``model`` with method ``get_followers()`` 160 | model.add_to_class('get_followers', get_object_followers) 161 | 162 | #2) patch ``User`` with method ``get_followed_Xs`` 163 | method_name = 'get_followed_' + model_name + 's' 164 | getter_method = make_followed_objects_getter(model) 165 | User.add_to_class(method_name, getter_method) 166 | 167 | #3) patch ``User with method ``is_following()`` 168 | if not hasattr(User, 'is_following'): 169 | User.add_to_class('is_following', test_follow_method) 170 | 171 | #4) patch ``User`` with method ``follow_X`` 172 | follow_method = make_follow_method(model) 173 | User.add_to_class('follow_' + model_name, follow_method) 174 | 175 | #5) patch ``User`` with method ``unfollow_X`` 176 | unfollow_method = make_unfollow_method(model) 177 | User.add_to_class('unfollow_' + model_name, unfollow_method) 178 | -------------------------------------------------------------------------------- /followit/compat.py: -------------------------------------------------------------------------------- 1 | # Src: https://github.com/toastdriven/django-tastypie/blob/master/tastypie/compat.py 2 | # Note: Appending original license in file itself. License files are hard :(. 3 | # 4 | # Copyright (c) 2010, Daniel Lindsley 5 | # All rights reserved. 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are met: 8 | # * Redistributions of source code must retain the above copyright 9 | # notice, this list of conditions and the following disclaimer. 10 | # * Redistributions in binary form must reproduce the above copyright 11 | # notice, this list of conditions and the following disclaimer in the 12 | # documentation and/or other materials provided with the distribution. 13 | # * Neither the name of the tastypie nor the 14 | # names of its contributors may be used to endorse or promote products 15 | # derived from this software without specific prior written permission. 16 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | # DISCLAIMED. IN NO EVENT SHALL tastypie BE LIABLE FOR ANY 20 | # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 21 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 23 | # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | 27 | from __future__ import unicode_literals 28 | from django.conf import settings 29 | from django.core.exceptions import ImproperlyConfigured 30 | import django 31 | 32 | __all__ = ['get_user_model',] 33 | 34 | # Django 1.5+ compatibility 35 | def get_user_model(): 36 | if django.VERSION >= (1, 5): 37 | try: 38 | from django.contrib import auth 39 | return auth.get_user_model() 40 | except ImproperlyConfigured: 41 | # The the users model might not be read yet. 42 | # This can happen is when setting up the create_api_key signal, in your 43 | # custom user module. 44 | return None 45 | else: 46 | from django.contrib.auth.models import User 47 | return User 48 | 49 | USER_MODEL_CLASS_NAME = getattr(settings, 'AUTH_USER_CLASS', 'auth.User') 50 | -------------------------------------------------------------------------------- /followit/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from django.conf import settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('contenttypes', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='FollowRecord', 18 | fields=[ 19 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 20 | ('object_id', models.PositiveIntegerField(db_index=True)), 21 | ('content_type', models.ForeignKey(related_name='followed_record_contenttype', to='contenttypes.ContentType', on_delete=models.CASCADE)), 22 | ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), 23 | ], 24 | options={ 25 | }, 26 | bases=(models.Model,), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /followit/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ASKBOT/django-followit/e62089d42c4f4a5b5f230da50f5d64d55362e2fd/followit/migrations/__init__.py -------------------------------------------------------------------------------- /followit/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.contenttypes.models import ContentType 3 | from followit.compat import USER_MODEL_CLASS_NAME 4 | 5 | class FollowRecord(models.Model): 6 | user = models.ForeignKey(USER_MODEL_CLASS_NAME, on_delete=models.CASCADE) 7 | content_type = models.ForeignKey( 8 | ContentType, 9 | related_name='followed_record_contenttype', 10 | on_delete=models.CASCADE 11 | ) 12 | object_id = models.PositiveIntegerField(db_index=True) 13 | -------------------------------------------------------------------------------- /followit/tests/README.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | How to run tests 3 | ================ 4 | 5 | This is a test app for the ``followit``. 6 | 7 | To run tests, jump into directory containing this file and launch the tests: 8 | (make sure django is installed in site-packages) 9 | 10 | python runtests.py 11 | -------------------------------------------------------------------------------- /followit/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig, apps 2 | from django.contrib.auth import get_user_model 3 | 4 | class TestConfig(AppConfig): 5 | name = 'followit.tests' 6 | 7 | def ready(self): 8 | import followit 9 | Car = apps.get_model('tests.Car') 10 | Alligator = apps.get_model('tests.Alligator') 11 | followit.register(Car) 12 | followit.register(Alligator) 13 | followit.register(get_user_model())#to test following users 14 | 15 | default_app_config = 'followit.tests.TestConfig' 16 | -------------------------------------------------------------------------------- /followit/tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "followit.tests.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /followit/tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from followit.compat import get_user_model 3 | 4 | class Car(models.Model): 5 | yeah = models.BooleanField(default = True) 6 | 7 | class Alligator(models.Model): 8 | yeah = models.BooleanField(default = True) 9 | 10 | -------------------------------------------------------------------------------- /followit/tests/runtests.py: -------------------------------------------------------------------------------- 1 | import django 2 | import os 3 | import sys 4 | 5 | RUNTESTS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', '..') 6 | sys.path.insert(0, os.path.dirname(RUNTESTS_DIR)) 7 | 8 | os.environ['DJANGO_SETTINGS_MODULE'] = 'followit.tests.settings' 9 | try: 10 | django.setup() 11 | except: # for django 1.7 12 | pass 13 | 14 | from followit.tests import settings 15 | from django.test.utils import get_runner 16 | 17 | TestRunner = get_runner(settings) 18 | test_runner = TestRunner(interactive=False) 19 | failures = test_runner.run_tests(['tests.FollowerTests']) 20 | 21 | if failures: 22 | sys.exit(failures) 23 | 24 | -------------------------------------------------------------------------------- /followit/tests/settings.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | DATABASES = { 5 | 'default': { 6 | 'ENGINE': 'django.db.backends.sqlite3', 7 | 'NAME': ':memory:', 8 | 'TEST_NAME': ':memory:', 9 | }, 10 | } 11 | 12 | MIDDLEWARE_CLASSES = () 13 | 14 | SECRET_KEY = ''.join([random.choice(string.ascii_letters) for x in range(40)]) 15 | 16 | TEST_RUNNER = 'django.test.runner.DiscoverRunner' 17 | 18 | INSTALLED_APPS = ( 19 | 'django.contrib.auth', 20 | 'django.contrib.contenttypes', 21 | 'followit', 22 | 'followit.tests' 23 | ) 24 | -------------------------------------------------------------------------------- /followit/tests/tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test cases for the follower module 3 | """ 4 | from django.test import TestCase 5 | from followit.compat import get_user_model 6 | import followit 7 | 8 | class FollowerTests(TestCase): 9 | def setUp(self): 10 | User = get_user_model() 11 | self.u1 = User.objects.create_user('user1', 'user1@example.com') 12 | self.u2 = User.objects.create_user('user2', 'user2@example.com') 13 | self.u3 = User.objects.create_user('user3', 'user3@example.com') 14 | followit.register(User) 15 | 16 | def test_multiple_follow(self): 17 | 18 | self.u1.follow_user(self.u2) 19 | self.u1.follow_user(self.u3) 20 | self.u2.follow_user(self.u1) 21 | 22 | self.assertEqual( 23 | set(self.u1.get_followers()), 24 | set([self.u2]) 25 | ) 26 | 27 | self.assertEqual( 28 | set(self.u2.get_followers()), 29 | set([self.u1]) 30 | ) 31 | 32 | self.assertEqual( 33 | set(self.u1.get_followed_users()), 34 | set([self.u2, self.u3]) 35 | ) 36 | 37 | def test_unfollow(self): 38 | self.u1.follow_user(self.u2) 39 | self.u1.unfollow_user(self.u2) 40 | self.assertEqual(self.u1.get_followed_users().count(), 0) 41 | 42 | def test_is_following(self): 43 | self.u2.follow_user(self.u1) 44 | self.assertTrue(self.u2.is_following(self.u1)) 45 | self.assertFalse(self.u1.is_following(self.u2)) 46 | -------------------------------------------------------------------------------- /followit/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from . import views as FollowitViews 4 | 5 | urlpatterns = [ 6 | url( 7 | r'^follow/(?P\w+)/(?P\d+)/$', 8 | FollowitViews.follow_object, 9 | name = 'follow_object' 10 | ), 11 | url( 12 | r'^unfollow/(?P\w+)/(?P\d+)/$', 13 | FollowitViews.unfollow_object, 14 | name = 'unfollow_object' 15 | ), 16 | url( 17 | r'^toggle-follow/(?P\w+)/(?P\d+)/$', 18 | FollowitViews.toggle_follow_object, 19 | name='toggle_follow_object' 20 | ) 21 | ] 22 | -------------------------------------------------------------------------------- /followit/utils.py: -------------------------------------------------------------------------------- 1 | """utility functions used by the :mod:`followit` 2 | """ 3 | import functools 4 | import json 5 | from django.http import HttpResponse 6 | import followit 7 | 8 | def get_object(model_name, object_id): 9 | """not a view, just a function, returning an object""" 10 | model = followit.REGISTRY[model_name] 11 | return model.objects.get(id = object_id) 12 | 13 | def del_related_objects_cache(model): 14 | try: 15 | del model._meta._related_objects_cache 16 | except AttributeError: 17 | pass 18 | try: 19 | del model._meta._related_objects_proxy_cache 20 | except AttributeError: 21 | pass 22 | 23 | def followit_ajax_view(view_func): 24 | """decorator that does certain error checks on the input 25 | and serializes response as json 26 | 27 | in the case of error, json output will contain 28 | """ 29 | @functools.wraps(view_func) 30 | def wrapped_view(request, model_name = None, object_id = None): 31 | try: 32 | assert(model_name in followit.REGISTRY) 33 | assert(request.user.is_authenticated()) 34 | assert(request.method == 'POST') 35 | assert(request.is_ajax()) 36 | data = view_func(request, model_name, object_id) 37 | except Exception as e: 38 | data = {'status': 'error', 'error_message': str(e)} 39 | 40 | return HttpResponse(json.dumps(data), content_type='application/json') 41 | return wrapped_view 42 | 43 | def post_only(view_func): 44 | """simple decorator raising assertion error when method is not 'POST""" 45 | @functools.wraps(view_func) 46 | def wrapped_view(request, *args, **kwargs): 47 | assert(request.method == 'POST') 48 | return view_func(request, *args, **kwargs) 49 | return wrapped_view 50 | 51 | -------------------------------------------------------------------------------- /followit/views.py: -------------------------------------------------------------------------------- 1 | """Views for the ``followit`` app, 2 | all are ajax views and return application/json mimetype 3 | """ 4 | from followit import utils 5 | 6 | @utils.followit_ajax_view 7 | @utils.post_only 8 | def follow_object(request, model_name = None, object_id = None): 9 | """follows an object and returns status: 10 | * 'success' - if an object was successfully followed 11 | * the decorator takes care of the error situations 12 | """ 13 | obj = utils.get_object(model_name, object_id) 14 | follow_func = getattr(request.user, 'follow_' + model_name) 15 | follow_func(obj) 16 | return {'status': 'success'} 17 | 18 | 19 | @utils.followit_ajax_view 20 | @utils.post_only 21 | def unfollow_object(request, model_name = None, object_id = None): 22 | """unfollows an object and returns status 'success' or 23 | 'error' - via the decorator :func:`~followit.utils.followit_ajax_view` 24 | """ 25 | obj = utils.get_object(model_name, object_id) 26 | unfollow_func = getattr(request.user, 'unfollow_' + model_name) 27 | unfollow_func(obj) 28 | return {'status': 'success'} 29 | 30 | 31 | @utils.followit_ajax_view 32 | @utils.post_only 33 | def toggle_follow_object(request, model_name = None, object_id = None): 34 | """if object is followed then unfollows 35 | otherwise follows 36 | 37 | returns json 38 | { 39 | 'status': 'success', # or 'error' 40 | 'following': True, #or False 41 | } 42 | 43 | 44 | unfollows an object and returns status 'success' or 45 | 'error' - via the decorator :func:`~followit.utils.followit_ajax_view` 46 | """ 47 | obj = utils.get_object(model_name, object_id) 48 | if request.user.is_following(obj): 49 | toggle_func = getattr(request.user, 'unfollow_' + model_name) 50 | following = False 51 | else: 52 | toggle_func = getattr(request.user, 'follow_' + model_name) 53 | following = True 54 | 55 | toggle_func(obj) 56 | return { 57 | 'status': 'success', 58 | 'following': following 59 | } 60 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import ez_setup 3 | ez_setup.use_setuptools() 4 | 5 | setup( 6 | name="django-followit", 7 | version='0.4.1', 8 | description='A Django application that allows users to follow django model objects', 9 | packages=find_packages(), 10 | author='Evgeny.Fadeev', 11 | author_email='evgeny.fadeev@gmail.com', 12 | license='BSD License', 13 | keywords='follow, database, django', 14 | url='https://github.com/ASKBOT/django-followit', 15 | include_package_data=True, 16 | classifiers=[ 17 | 'Development Status :: 4 - Beta', 18 | 'Environment :: Web Environment', 19 | 'Framework :: Django', 20 | 'Intended Audience :: Developers', 21 | 'License :: OSI Approved :: BSD License', 22 | 'Operating System :: OS Independent', 23 | 'Programming Language :: Python :: 2.7', 24 | 'Programming Language :: Python :: 3.6', 25 | 'Programming Language :: Python :: 3.7', 26 | 'Programming Language :: JavaScript', 27 | 'Topic :: Internet :: WWW/HTTP :: WSGI :: Application', 28 | ], 29 | long_description=''.join(open('README.rst', 'r').readlines()[4:]), 30 | long_description_content_type='text/x-rst' 31 | ) 32 | --------------------------------------------------------------------------------