├── locking
├── migrations
│ ├── __init__.py
│ └── 0001_initial.py
├── south_migrations
│ ├── __init__.py
│ ├── 0001_initial.py
│ └── 0002_auto__del_field_lock_app__del_field_lock_entry_id__del_field_lock_mode.py
├── tests
│ ├── __init__.py
│ ├── urls.py
│ ├── admin.py
│ ├── models.py
│ ├── utils.py
│ ├── fixtures
│ │ └── locking_scenario.json
│ └── tests.py
├── utils.py
├── static
│ └── locking
│ │ ├── img
│ │ ├── lock.png
│ │ └── page_edit.png
│ │ ├── css
│ │ └── locking.css
│ │ └── js
│ │ └── admin.locking.js
├── __init__.py
├── locale
│ └── vertalingen.txt
├── urls.py
├── decorators.py
├── managers.py
├── settings.py
├── forms.py
├── views.py
├── admin.py
└── models.py
├── .gitignore
├── MANIFEST.in
├── docs
├── _themes
│ ├── theme.conf
│ ├── nature
│ │ ├── theme.conf
│ │ └── static
│ │ │ ├── pygments.css
│ │ │ └── nature.css_t
│ └── static
│ │ ├── pygments.css
│ │ └── nature.css_t
├── screenshots
│ ├── hard_lock.png
│ ├── auto_unlock.png
│ ├── lock_by_who.png
│ ├── locked-list.png
│ ├── expire_status.png
│ ├── reload_or_bust.png
│ ├── unlock_prompt.png
│ ├── locked-editscreen.png
│ └── locked-list-by-me.png
├── credits.rst
├── ponies.rst
├── changelog.rst
├── developers.rst
├── api.rst
├── pip-log.txt
├── design.rst
├── Makefile
├── index.rst
└── conf.py
├── setup.py
├── LICENSE
└── README.md
/locking/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/locking/south_migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/locking/tests/__init__.py:
--------------------------------------------------------------------------------
1 | from tests import *
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | .DS_Store
3 | docs/_build
4 | *.egg-info
5 | build
6 | dist
7 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include README.md
2 | include LICENSE
3 | recursive-include locking/static *
4 |
--------------------------------------------------------------------------------
/docs/_themes/theme.conf:
--------------------------------------------------------------------------------
1 | [theme]
2 | inherit = basic
3 | stylesheet = nature.css
4 | pygments_style = tango
5 |
--------------------------------------------------------------------------------
/locking/utils.py:
--------------------------------------------------------------------------------
1 | def timedelta_to_seconds(delta):
2 | return delta.days * 24 * 60 * 60 + delta.seconds
3 |
--------------------------------------------------------------------------------
/docs/_themes/nature/theme.conf:
--------------------------------------------------------------------------------
1 | [theme]
2 | inherit = basic
3 | stylesheet = nature.css
4 | pygments_style = tango
5 |
--------------------------------------------------------------------------------
/docs/screenshots/hard_lock.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theatlantic/django-locking/HEAD/docs/screenshots/hard_lock.png
--------------------------------------------------------------------------------
/docs/screenshots/auto_unlock.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theatlantic/django-locking/HEAD/docs/screenshots/auto_unlock.png
--------------------------------------------------------------------------------
/docs/screenshots/lock_by_who.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theatlantic/django-locking/HEAD/docs/screenshots/lock_by_who.png
--------------------------------------------------------------------------------
/docs/screenshots/locked-list.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theatlantic/django-locking/HEAD/docs/screenshots/locked-list.png
--------------------------------------------------------------------------------
/docs/screenshots/expire_status.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theatlantic/django-locking/HEAD/docs/screenshots/expire_status.png
--------------------------------------------------------------------------------
/docs/screenshots/reload_or_bust.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theatlantic/django-locking/HEAD/docs/screenshots/reload_or_bust.png
--------------------------------------------------------------------------------
/docs/screenshots/unlock_prompt.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theatlantic/django-locking/HEAD/docs/screenshots/unlock_prompt.png
--------------------------------------------------------------------------------
/locking/static/locking/img/lock.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theatlantic/django-locking/HEAD/locking/static/locking/img/lock.png
--------------------------------------------------------------------------------
/docs/screenshots/locked-editscreen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theatlantic/django-locking/HEAD/docs/screenshots/locked-editscreen.png
--------------------------------------------------------------------------------
/docs/screenshots/locked-list-by-me.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theatlantic/django-locking/HEAD/docs/screenshots/locked-list-by-me.png
--------------------------------------------------------------------------------
/locking/static/locking/img/page_edit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theatlantic/django-locking/HEAD/locking/static/locking/img/page_edit.png
--------------------------------------------------------------------------------
/locking/__init__.py:
--------------------------------------------------------------------------------
1 | import pkg_resources
2 |
3 | try:
4 | __version__ = pkg_resources.get_distribution('django-locking').version
5 | except pkg_resources.DistributionNotFound:
6 | __version__ = None
7 |
--------------------------------------------------------------------------------
/locking/tests/urls.py:
--------------------------------------------------------------------------------
1 | from django.conf.urls import patterns, include
2 | from django.contrib import admin
3 |
4 |
5 | admin.autodiscover()
6 |
7 |
8 | urlpatterns = patterns('',
9 | (r'^ajax/admin/', include('locking.urls')),
10 | (r'^admin/', include(admin.site.urls)),
11 | (r'', include('staticfiles.urls')),
12 | )
13 |
--------------------------------------------------------------------------------
/locking/tests/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from locking.tests.models import Story
3 | from locking.admin import LockableAdmin
4 |
5 |
6 | class StoryAdmin(LockableAdmin):
7 |
8 | list_display = ('lock', 'content', )
9 | list_display_links = ('content', )
10 |
11 |
12 | admin.site.register(Story, StoryAdmin)
13 |
--------------------------------------------------------------------------------
/locking/locale/vertalingen.txt:
--------------------------------------------------------------------------------
1 | Binnen vijf minuten verdwijnt het slot op dit artikel. Save het artikel en navigeer terug naar de wijzigingspagina om het artikel opnieuw voor %(minutes)s minuten te sluiten.
2 |
3 |
Dit artikel wordt momenteel door %(user)s aangepast. Je kan het lezen maar niet bewerken.
4 |
5 | Nog %s minuten gesloten voor %s
--------------------------------------------------------------------------------
/locking/urls.py:
--------------------------------------------------------------------------------
1 | from django.conf.urls import url
2 | import django.views.i18n
3 |
4 | from warnings import warn
5 |
6 |
7 | warn("The use of 'locking.urls' is deprecated and is no longer needed.",
8 | DeprecationWarning)
9 |
10 |
11 | # We need at least one url inside urlpatterns to keep include('locking.urls')
12 | # from throwing an exception
13 | urlpatterns = [
14 | url(r'jsi18n/$', django.views.i18n.javascript_catalog, {'packages': 'locking'}),
15 | ]
16 |
--------------------------------------------------------------------------------
/locking/tests/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 | from locking import models as locking
3 |
4 |
5 | class Story(locking.LockableModel):
6 |
7 | content = models.TextField(blank=True)
8 |
9 | class Meta:
10 | verbose_name_plural = 'stories'
11 |
12 |
13 | class Unlockable(models.Model):
14 | """
15 | This model serves to test that utils.gather_lockable_models
16 | actually does what it's supposed to
17 | """
18 | content = models.TextField(blank=True)
19 |
--------------------------------------------------------------------------------
/locking/static/locking/css/locking.css:
--------------------------------------------------------------------------------
1 | #locking_notification {
2 | border: 2px solid #f00;
3 | padding: 5px;
4 | background: #fee;
5 | display: inline-block;
6 | font-weight: bold;
7 | display: none;
8 | width: 510px;
9 | margin-top: 10px;
10 | }
11 |
12 | a.locking-status {
13 | display: block;
14 | width: 16px;
15 | height: 16px;
16 | }
17 |
18 | a.locking-status.locking-locked {
19 | background: transparent url(../img/lock.png) no-repeat 0 0;
20 | }
21 |
22 | a.locking-status.locking-edit {
23 | background: transparent url(../img/page_edit.png) no-repeat 0 0;
24 | cursor: default;
25 | }
--------------------------------------------------------------------------------
/docs/credits.rst:
--------------------------------------------------------------------------------
1 | ==================
2 | About this project
3 | ==================
4 |
5 | License
6 | -------
7 |
8 | ``django-locking`` is released under a simplified BSD license, with the exception of two bits of included software:
9 |
10 | * jQuery, which is dual-licensed (GPL and MIT)
11 | * the jQuery URL Parser which also has an MIT-like license
12 | * icons by Mark James (thanks, Mark!) that are CC-licensed (Attribution 2.5)
13 |
14 | Essentially, these licenses allow you to use the software and its constituent parts for any purpose you can think of. Read up on the BSD and MIT licenses if you're interested in the nitty-gritty details and legalese.
15 |
16 | Credits
17 | -------
18 |
19 | ``django-locking`` relies on icons from Mark James. Thanks, Mark! Check out http://www.famfamfam.com. It also makes use of the excellent jQuery library and the jQuery URL Parser.
--------------------------------------------------------------------------------
/locking/decorators.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from django.http import HttpResponse
3 |
4 |
5 | logger = logging.getLogger('django.locker')
6 |
7 |
8 | def user_may_change_model(fn):
9 | def view(request, app, model, *vargs, **kwargs):
10 | may_change = '%s.change_%s' % (app, model)
11 | if not request.user.has_perm(may_change):
12 | return HttpResponse(status=401)
13 | else:
14 | return fn(request, app, model, *vargs, **kwargs)
15 |
16 | return view
17 |
18 |
19 | def is_lockable(fn):
20 | def view(request, app, model, *vargs, **kwargs):
21 | return fn(request, app, model, *vargs, **kwargs)
22 | return view
23 |
24 |
25 | def log(view):
26 | def decorated_view(*vargs, **kwargs):
27 | response = view(*vargs, **kwargs)
28 | logger.debug("Sending a request: \n\t%s" % (response.content))
29 | return response
30 |
31 | return decorated_view
32 |
--------------------------------------------------------------------------------
/locking/tests/utils.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.core.management import call_command
3 | from django.db.models import loading
4 | from django import test
5 |
6 |
7 | class TestCase(test.TestCase):
8 |
9 | apps = ()
10 |
11 | def _pre_setup(self):
12 | # Add the models to the db.
13 | self._original_installed_apps = list(settings.INSTALLED_APPS)
14 | for app in self.apps:
15 | settings.INSTALLED_APPS.append(app)
16 | loading.cache.loaded = False
17 | call_command('syncdb', interactive=False, verbosity=0)
18 | # Call the original method that does the fixtures etc.
19 | super(TestCase, self)._pre_setup()
20 |
21 | def _post_teardown(self):
22 | # Call the original method.
23 | super(TestCase, self)._post_teardown()
24 | # Restore the settings.
25 | settings.INSTALLED_APPS = self._original_installed_apps
26 | loading.cache.loaded = False
27 |
--------------------------------------------------------------------------------
/docs/ponies.rst:
--------------------------------------------------------------------------------
1 | =======
2 | Roadmap
3 | =======
4 |
5 | Things that are planned
6 | -----------------------
7 |
8 | * manual overrides by admins (in the UI)
9 | * enhance the warning dialog users see five minutes prior to expiry, to allow users to renew their lock
10 | * make it so that locks do not trigger the ``auto_now`` or ``auto_now_add`` behavior of DateFields and DateTimeFields
11 |
12 | Someday/maybe
13 | -------------
14 |
15 | * running the test suite through setup.py
16 | * minimize dependence on javascript for soft locks, by using a middleware and Django's 1.2 ``read_only_fields``. ``django-locking`` won't degrade entirely gracefully, but we do want to make sure it doesn't degrade quite so *ungracefully* as it does now.
17 | * give end-developers a choice whether they want the LockableModel fields on the model itself (cleaner) or added with a OneToOneField instead (less hassle migrating if you're not using South__)
18 | * userless locking (might be interesting if you want to lock stuff that a process is doing number crunching on, or something similar)
19 |
20 | .. __: http://south.aeracode.org/
--------------------------------------------------------------------------------
/docs/changelog.rst:
--------------------------------------------------------------------------------
1 | =========
2 | Changelog
3 | =========
4 |
5 | 1.0 (pending)
6 | -------------
7 |
8 | * improve the test coverage with web client tests
9 | * i18n: Dutch translation
10 | * manual overrides by admins (in the UI)
11 | * enhance the warning dialog users see five minutes prior to expiry, to allow users to renew their lock
12 | * make it so that locks do not trigger the ``auto_now`` or ``auto_now_add`` behavior of DateFields and DateTimeFields
13 |
14 | 0.3
15 | ---
16 |
17 | * Hard locks and soft locks (see :doc:`design`)
18 | * improved test coverage with web client tests
19 | * `locked` and `unlocked` managers on the `LockableModel` base model.
20 |
21 | 0.2
22 | ---
23 |
24 | * Initial open-source release
25 | * Added packaging for PyPI
26 | * Added a bunch of documentation, both for end-developers and to explain its underlying design
27 | * Got rid of some assumptions and various little bits of hardcoding. E.g. urls are now constructed using Django's ``django.core.urlresolvers.reverse`` wherever possible.
28 | * Static media serving using ``django-staticfiles``
29 | * Added unit tests
30 |
31 | 0.1
32 | ---
33 |
34 | * Internal release
--------------------------------------------------------------------------------
/locking/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='Lock',
18 | fields=[
19 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
20 | ('object_id', models.PositiveIntegerField()),
21 | ('_locked_at', models.DateTimeField(null=True, editable=False, db_column=b'locked_at')),
22 | ('_hard_lock', models.BooleanField(default=False, editable=False, db_column=b'hard_lock')),
23 | ('_locked_by', models.ForeignKey(related_name='working_on_locking_lock', db_column=b'locked_by', editable=False, to=settings.AUTH_USER_MODEL, null=True)),
24 | ('content_type', models.ForeignKey(to='contenttypes.ContentType')),
25 | ],
26 | options={
27 | 'ordering': ('-_locked_at',),
28 | },
29 | bases=(models.Model,),
30 | ),
31 | ]
32 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import os
2 | from setuptools import setup, find_packages
3 | README = os.path.join(os.path.dirname(__file__), 'README.md')
4 | long_description = open(README).read()
5 | setup(name='django-locking',
6 | version='2.2.19',
7 | description=("Prevents users from doing concurrent editing in Django. Works out of the box in the admin interface, or you can integrate it with your own apps using a public API."),
8 | long_description=long_description,
9 | classifiers=['Development Status :: 4 - Beta',
10 | 'Environment :: Web Environment',
11 | 'Framework :: Django',
12 | 'Intended Audience :: Developers',
13 | 'License :: OSI Approved :: BSD License',
14 | 'Operating System :: OS Independent',
15 | 'Programming Language :: Python',
16 | 'Topic :: Software Development :: Libraries :: Python Modules',
17 | 'Topic :: Utilities'],
18 | keywords='locking mutex',
19 | author='Rob Combs',
20 | author_email='robert.combs@coxinc.com',
21 | url='http://www.github.com/RobCombs/django-locking/',
22 | download_url='http://www.github.com/RobCombs/django-locking/tarball/master',
23 | license='BSD',
24 | packages=find_packages(),
25 | install_requires=['django-staticfiles'],
26 | include_package_data=True)
27 |
--------------------------------------------------------------------------------
/locking/managers.py:
--------------------------------------------------------------------------------
1 | import django
2 | from django.db.models import Q, Manager
3 | from locking import settings as locking_settings
4 | import datetime
5 |
6 | """
7 | LOCKED
8 | if (datetime.today() - self.locked_at).seconds < LOCK_TIMEOUT:
9 |
10 |
11 | self.locked_at < (NOW - TIMEOUT)
12 | """
13 |
14 | def point_of_timeout():
15 | delta = datetime.timedelta(seconds=locking_settings.LOCK_TIMEOUT)
16 | return datetime.datetime.now() - delta
17 |
18 | class LockedManager(Manager):
19 |
20 | def get_queryset(self):
21 | timeout = point_of_timeout()
22 | if django.VERSION < (1, 7):
23 | qs = super(LockedManager, self).get_query_set()
24 | else:
25 | qs = super(LockedManager, self).get_queryset()
26 | return qs.filter(_locked_at__gt=timeout, _locked_at__isnull=False)
27 |
28 | if django.VERSION < (1, 7):
29 | get_query_set = get_queryset
30 |
31 |
32 | class UnlockedManager(Manager):
33 |
34 | def get_queryset(self):
35 | timeout = point_of_timeout()
36 | if django.VERSION < (1, 7):
37 | qs = super(UnlockedManager, self).get_query_set()
38 | else:
39 | qs = super(UnlockedManager, self).get_queryset()
40 | return qs.filter(Q(_locked_at__lte=timeout) | Q(_locked_at__isnull=True))
41 |
42 | if django.VERSION < (1, 7):
43 | get_query_set = get_queryset
44 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2010 Stijn Debrouwere. All rights reserved.
2 |
3 | Redistribution and use in source and binary forms, with or without modification, are
4 | permitted provided that the following conditions are met:
5 |
6 | 1. Redistributions of source code must retain the above copyright notice, this list of
7 | conditions and the following disclaimer.
8 |
9 | 2. Redistributions in binary form must reproduce the above copyright notice, this list
10 | of conditions and the following disclaimer in the documentation and/or other materials
11 | provided with the distribution.
12 |
13 | THIS SOFTWARE IS PROVIDED BY STIJN DEBROUWERE ``AS IS'' AND ANY EXPRESS OR IMPLIED
14 | WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
15 | FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL STIJN DEBROUWERE OR
16 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
17 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
18 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
19 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
20 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
21 | ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
22 |
23 | The views and conclusions contained in the software and documentation are those of the
24 | authors and should not be interpreted as representing official policies, either expressed
25 | or implied, of Stijn Debrouwere.
--------------------------------------------------------------------------------
/locking/settings.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 |
3 |
4 | LOCKING_URL = getattr(settings, 'LOCKING_URL', '/locking/')
5 | STATIC_URL = getattr(settings, 'STATIC_URL', '/static/')
6 |
7 |
8 | def get_timedelta_setting(key, default=None):
9 | # Importing inside function to keep exports cleaner
10 | import collections
11 | from datetime import timedelta
12 | from django.core.exceptions import ImproperlyConfigured
13 |
14 | LOCKING_SETTINGS = getattr(settings, 'LOCKING', {})
15 |
16 | value = LOCKING_SETTINGS.get(key, default)
17 | try:
18 | if isinstance(value, timedelta):
19 | pass
20 | elif isinstance(value, collections.Mapping):
21 | value = timedelta(**value)
22 | else:
23 | value = timedelta(seconds=value)
24 | except TypeError:
25 | raise ImproperlyConfigured((
26 | "LOCKING_SETTINGS['%(key)s'] must be either a datetime.timedelta "
27 | "object, a dict of kwargs pass to datetime.timedelta, or a "
28 | "number of seconds (int); instead got %(type)s" % {
29 | 'key': key,
30 | 'type': type(value).__name__,
31 | }))
32 |
33 | return value
34 |
35 |
36 | TIME_UNTIL_EXPIRATION = get_timedelta_setting('time_until_expiration', 600)
37 | TIME_UNTIL_WARNING = get_timedelta_setting('time_until_warning', 540)
38 | # The time it takes for a lock created when a user clears another lock,
39 | # before the object returns to an unlocked state.
40 | LOCK_CLEAR_TIMEOUT = get_timedelta_setting('lock_clear_timeout', 30)
41 | LOCK_TIMEOUT = getattr(settings, 'LOCK_TIMEOUT', 1800)
42 |
--------------------------------------------------------------------------------
/docs/developers.rst:
--------------------------------------------------------------------------------
1 | Developers' documentation
2 | =========================
3 |
4 | The public API
5 | --------------
6 |
7 | ``django-locking`` has a concise API, revolving around the ``LockableModel``. You can read more about how to interact with this API in :doc:`api`.
8 |
9 | Running the test suite
10 | ----------------------
11 |
12 | Before running the test suite, make sure you've added ``locking`` and ``locking.tests`` to your ``INSTALLED_APPS`` in ``settings.py``. Also add ``(r'^ajax/admin/', include(locking.urls)),`` to your urlconf (don't forget ``import locking``). You may then run the test suite using ``python manage.py test locking``.
13 |
14 | Building the documentation
15 | --------------------------
16 |
17 | Building the documentation can be done by cd'ing to the ``/docs`` directory and executing ``make build html``. The documentation for Sphinx (the tool used to build the documentation) can be found here__, and a reStructured Text primer, which explains the markup language can be found here__.
18 |
19 | .. __: http://sphinx.pocoo.org/index.html
20 |
21 | .. __: http://sphinx.pocoo.org/rest.html
22 |
23 | Help out
24 | --------
25 |
26 | If you'd like to help out with further development: fork away!
27 |
28 | Design and other resources
29 | --------------------------
30 |
31 | You can learn a bit more about the rationale behind how ``django-locking`` works over at :doc:`design`.
32 |
33 | You might also want to check out these web pages and see what kind of locking solutions are already out there:
34 |
35 | * http://www.reddit.com/r/django/comments/c8ts2/edit_locking_in_the_admin_anyone_ever_done_this/
36 | * http://stackoverflow.com/questions/698950/what-is-the-simplest-way-to-lock-an-object-in-django
37 | * http://djangosnippets.org/tags/lock/
--------------------------------------------------------------------------------
/docs/api.rst:
--------------------------------------------------------------------------------
1 | ==============
2 | The public API
3 | ==============
4 |
5 | Some examples
6 | -------------
7 |
8 | .. highlight:: python
9 |
10 | Let's import some models and fixtures to play around with.
11 |
12 | >>> from locking.tests.models import Story
13 | >>> from django.contrib.auth.models import User
14 | >>> user = User.objects.all()[0]
15 | >>> story = Story.objects.all()[0]
16 |
17 | Let's lock a story.
18 |
19 | >>> story.lock_for(user)
20 | INFO:root:Attempting to initiate a lock for user `stdbrouw`
21 | INFO:root:Initiated a lock for `stdbrouw` at 2010-06-01 09:33:46.540376
22 | # We can access all kind of information about the lock
23 | >>> story.locked_at
24 | datetime.datetime(2010, 6, 1, 9, 38, 3, 101238)
25 | >>> story.locked_by
26 |
27 | >>> story.is_locked
28 | True
29 | >>> story.lock_seconds_remaining
30 | 1767
31 | # Remember: a lock isn't actually active until we save it to the database!
32 | >>> story.save()
33 |
34 | And we can unlock again. Although it's possible to force an unlock, it's better to unlock specifically for the user that locked the content in the first place -- that way django-locking can protest if the wrong user tries to unlock something.
35 |
36 | >>> story.unlock_for(user)
37 | INFO:root:Attempting to open up a lock on `Story object` by user `blub`
38 | INFO:root:Attempting to initiate a lock for user `False`
39 | INFO:root:Freed lock on `Story object`
40 | True
41 | >>> story.save()
42 |
43 | Additionally, the LockableModel class defines three `managers `_: ``objects``, ``locked`` and ``unlocked``, that unsurprisingly give you access to, respectively, all objects, locked objects and unlocked objects.
44 |
45 | Methods and attributes
46 | ----------------------
47 |
48 | Most functionality and domain logic of ``django-locking`` resides in the ``LockableModel``, with the views providing little more than an interface to the web.
49 |
50 | .. automodule:: locking.models
51 | :show-inheritance:
52 | :members:
53 | :undoc-members:
54 |
55 | Nomenclature
56 | ------------
57 |
58 | ``django-locking`` tries to be consistent in its terminology, even if it doesn't always succeed. An object can be **locked** and **unlocked**, in which case we've **disengaged** a lock. A lock can **apply** to a certain user, or not apply because it was **initiated** by that same user. A lock will **expire** once it has been in place longer than a predefined **timeout**.
--------------------------------------------------------------------------------
/locking/tests/fixtures/locking_scenario.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "pk": 1,
4 | "model": "tests.story",
5 | "fields": {
6 | "content": "This is a little lockable story.",
7 | "_locked_at": "2010-05-28 11:10:05",
8 | "_locked_by": 2
9 | }
10 | },
11 | {
12 | "pk": 2,
13 | "model": "tests.story",
14 | "fields": {
15 | "content": "This is another article ready for locking and unlocking.",
16 | "_locked_at": null,
17 | "_locked_by": null
18 | }
19 | },
20 | {
21 | "pk": 1,
22 | "model": "tests.unlockable",
23 | "fields": {
24 | "content": "This is an object that doesn't have LockableModel as a base class."
25 | }
26 | },
27 | {
28 | "pk": 22,
29 | "model": "auth.permission",
30 | "fields": {
31 | "codename": "add_story",
32 | "name": "Can add story",
33 | "content_type": 8
34 | }
35 | },
36 | {
37 | "pk": 23,
38 | "model": "auth.permission",
39 | "fields": {
40 | "codename": "change_story",
41 | "name": "Can change story",
42 | "content_type": 8
43 | }
44 | },
45 | {
46 | "pk": 24,
47 | "model": "auth.permission",
48 | "fields": {
49 | "codename": "delete_story",
50 | "name": "Can delete story",
51 | "content_type": 8
52 | }
53 | },
54 | {
55 | "pk": 1,
56 | "model": "auth.user",
57 | "fields": {
58 | "username": "Stan",
59 | "first_name": "",
60 | "last_name": "",
61 | "is_active": true,
62 | "is_superuser": true,
63 | "is_staff": true,
64 | "last_login": "2010-05-28 07:03:47",
65 | "groups": [],
66 | "user_permissions": [],
67 | "password": "sha1$8aacc$d4ddcce2942a31430eb29a00c1d8a314e5577f8b",
68 | "email": "",
69 | "date_joined": "2010-05-28 04:54:28"
70 | }
71 | },
72 | {
73 | "pk": 2,
74 | "model": "auth.user",
75 | "fields": {
76 | "username": "Fred",
77 | "first_name": "",
78 | "last_name": "",
79 | "is_active": true,
80 | "is_superuser": false,
81 | "is_staff": false,
82 | "last_login": "2010-05-28 10:22:49",
83 | "groups": [],
84 | "user_permissions": [],
85 | "password": "sha1$802af$9cb1b9b3998fccb7fdd9cb0e493a505d43e033d3",
86 | "email": "",
87 | "date_joined": "2010-05-28 10:22:49"
88 | }
89 | }
90 | ]
--------------------------------------------------------------------------------
/docs/_themes/static/pygments.css:
--------------------------------------------------------------------------------
1 | .c { color: #999988; font-style: italic } /* Comment */
2 | .k { font-weight: bold } /* Keyword */
3 | .o { font-weight: bold } /* Operator */
4 | .cm { color: #999988; font-style: italic } /* Comment.Multiline */
5 | .cp { color: #999999; font-weight: bold } /* Comment.preproc */
6 | .c1 { color: #999988; font-style: italic } /* Comment.Single */
7 | .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */
8 | .ge { font-style: italic } /* Generic.Emph */
9 | .gr { color: #aa0000 } /* Generic.Error */
10 | .gh { color: #999999 } /* Generic.Heading */
11 | .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */
12 | .go { color: #111 } /* Generic.Output */
13 | .gp { color: #555555 } /* Generic.Prompt */
14 | .gs { font-weight: bold } /* Generic.Strong */
15 | .gu { color: #aaaaaa } /* Generic.Subheading */
16 | .gt { color: #aa0000 } /* Generic.Traceback */
17 | .kc { font-weight: bold } /* Keyword.Constant */
18 | .kd { font-weight: bold } /* Keyword.Declaration */
19 | .kp { font-weight: bold } /* Keyword.Pseudo */
20 | .kr { font-weight: bold } /* Keyword.Reserved */
21 | .kt { color: #445588; font-weight: bold } /* Keyword.Type */
22 | .m { color: #009999 } /* Literal.Number */
23 | .s { color: #bb8844 } /* Literal.String */
24 | .na { color: #008080 } /* Name.Attribute */
25 | .nb { color: #999999 } /* Name.Builtin */
26 | .nc { color: #445588; font-weight: bold } /* Name.Class */
27 | .no { color: #ff99ff } /* Name.Constant */
28 | .ni { color: #800080 } /* Name.Entity */
29 | .ne { color: #990000; font-weight: bold } /* Name.Exception */
30 | .nf { color: #990000; font-weight: bold } /* Name.Function */
31 | .nn { color: #555555 } /* Name.Namespace */
32 | .nt { color: #000080 } /* Name.Tag */
33 | .nv { color: purple } /* Name.Variable */
34 | .ow { font-weight: bold } /* Operator.Word */
35 | .mf { color: #009999 } /* Literal.Number.Float */
36 | .mh { color: #009999 } /* Literal.Number.Hex */
37 | .mi { color: #009999 } /* Literal.Number.Integer */
38 | .mo { color: #009999 } /* Literal.Number.Oct */
39 | .sb { color: #bb8844 } /* Literal.String.Backtick */
40 | .sc { color: #bb8844 } /* Literal.String.Char */
41 | .sd { color: #bb8844 } /* Literal.String.Doc */
42 | .s2 { color: #bb8844 } /* Literal.String.Double */
43 | .se { color: #bb8844 } /* Literal.String.Escape */
44 | .sh { color: #bb8844 } /* Literal.String.Heredoc */
45 | .si { color: #bb8844 } /* Literal.String.Interpol */
46 | .sx { color: #bb8844 } /* Literal.String.Other */
47 | .sr { color: #808000 } /* Literal.String.Regex */
48 | .s1 { color: #bb8844 } /* Literal.String.Single */
49 | .ss { color: #bb8844 } /* Literal.String.Symbol */
50 | .bp { color: #999999 } /* Name.Builtin.Pseudo */
51 | .vc { color: #ff99ff } /* Name.Variable.Class */
52 | .vg { color: #ff99ff } /* Name.Variable.Global */
53 | .vi { color: #ff99ff } /* Name.Variable.Instance */
54 | .il { color: #009999 } /* Literal.Number.Integer.Long */
--------------------------------------------------------------------------------
/docs/_themes/nature/static/pygments.css:
--------------------------------------------------------------------------------
1 | .c { color: #999988; font-style: italic } /* Comment */
2 | .k { font-weight: bold } /* Keyword */
3 | .o { font-weight: bold } /* Operator */
4 | .cm { color: #999988; font-style: italic } /* Comment.Multiline */
5 | .cp { color: #999999; font-weight: bold } /* Comment.preproc */
6 | .c1 { color: #999988; font-style: italic } /* Comment.Single */
7 | .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */
8 | .ge { font-style: italic } /* Generic.Emph */
9 | .gr { color: #aa0000 } /* Generic.Error */
10 | .gh { color: #999999 } /* Generic.Heading */
11 | .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */
12 | .go { color: #111 } /* Generic.Output */
13 | .gp { color: #555555 } /* Generic.Prompt */
14 | .gs { font-weight: bold } /* Generic.Strong */
15 | .gu { color: #aaaaaa } /* Generic.Subheading */
16 | .gt { color: #aa0000 } /* Generic.Traceback */
17 | .kc { font-weight: bold } /* Keyword.Constant */
18 | .kd { font-weight: bold } /* Keyword.Declaration */
19 | .kp { font-weight: bold } /* Keyword.Pseudo */
20 | .kr { font-weight: bold } /* Keyword.Reserved */
21 | .kt { color: #445588; font-weight: bold } /* Keyword.Type */
22 | .m { color: #009999 } /* Literal.Number */
23 | .s { color: #bb8844 } /* Literal.String */
24 | .na { color: #008080 } /* Name.Attribute */
25 | .nb { color: #999999 } /* Name.Builtin */
26 | .nc { color: #445588; font-weight: bold } /* Name.Class */
27 | .no { color: #ff99ff } /* Name.Constant */
28 | .ni { color: #800080 } /* Name.Entity */
29 | .ne { color: #990000; font-weight: bold } /* Name.Exception */
30 | .nf { color: #990000; font-weight: bold } /* Name.Function */
31 | .nn { color: #555555 } /* Name.Namespace */
32 | .nt { color: #000080 } /* Name.Tag */
33 | .nv { color: purple } /* Name.Variable */
34 | .ow { font-weight: bold } /* Operator.Word */
35 | .mf { color: #009999 } /* Literal.Number.Float */
36 | .mh { color: #009999 } /* Literal.Number.Hex */
37 | .mi { color: #009999 } /* Literal.Number.Integer */
38 | .mo { color: #009999 } /* Literal.Number.Oct */
39 | .sb { color: #bb8844 } /* Literal.String.Backtick */
40 | .sc { color: #bb8844 } /* Literal.String.Char */
41 | .sd { color: #bb8844 } /* Literal.String.Doc */
42 | .s2 { color: #bb8844 } /* Literal.String.Double */
43 | .se { color: #bb8844 } /* Literal.String.Escape */
44 | .sh { color: #bb8844 } /* Literal.String.Heredoc */
45 | .si { color: #bb8844 } /* Literal.String.Interpol */
46 | .sx { color: #bb8844 } /* Literal.String.Other */
47 | .sr { color: #808000 } /* Literal.String.Regex */
48 | .s1 { color: #bb8844 } /* Literal.String.Single */
49 | .ss { color: #bb8844 } /* Literal.String.Symbol */
50 | .bp { color: #999999 } /* Name.Builtin.Pseudo */
51 | .vc { color: #ff99ff } /* Name.Variable.Class */
52 | .vg { color: #ff99ff } /* Name.Variable.Global */
53 | .vi { color: #ff99ff } /* Name.Variable.Instance */
54 | .il { color: #009999 } /* Literal.Number.Integer.Long */
--------------------------------------------------------------------------------
/docs/pip-log.txt:
--------------------------------------------------------------------------------
1 | Downloading/unpacking sphinx-ext-autodoc
2 | Getting page http://pypi.python.org/simple/sphinx-ext-autodoc
3 | Could not fetch URL http://pypi.python.org/simple/sphinx-ext-autodoc: HTTP Error 404: Not Found
4 | Will skip URL http://pypi.python.org/simple/sphinx-ext-autodoc when looking for download links for sphinx-ext-autodoc
5 | Getting page http://pypi.python.org/simple/
6 | URLs to search for versions for sphinx-ext-autodoc:
7 | * http://pypi.python.org/simple/sphinx-ext-autodoc/
8 | Getting page http://pypi.python.org/simple/sphinx-ext-autodoc/
9 | Could not fetch URL http://pypi.python.org/simple/sphinx-ext-autodoc/: HTTP Error 404: Not Found
10 | Will skip URL http://pypi.python.org/simple/sphinx-ext-autodoc/ when looking for download links for sphinx-ext-autodoc
11 | Could not find any downloads that satisfy the requirement sphinx-ext-autodoc
12 | No distributions at all found for sphinx-ext-autodoc
13 | Exception information:
14 | Traceback (most recent call last):
15 | File "/Library/Python/2.6/site-packages/pip.py", line 274, in main
16 | self.run(options, args)
17 | File "/Library/Python/2.6/site-packages/pip.py", line 431, in run
18 | requirement_set.install_files(finder, force_root_egg_info=self.bundle)
19 | File "/Library/Python/2.6/site-packages/pip.py", line 1813, in install_files
20 | url = finder.find_requirement(req_to_install, upgrade=self.upgrade)
21 | File "/Library/Python/2.6/site-packages/pip.py", line 1086, in find_requirement
22 | raise DistributionNotFound('No distributions at all found for %s' % req)
23 | DistributionNotFound: No distributions at all found for sphinx-ext-autodoc
24 | ------------------------------------------------------------
25 | /usr/local/bin/pip run on Tue Jun 1 15:31:20 2010
26 | Downloading/unpacking sphinx-ext-autodoc
27 | Getting page http://pypi.python.org/simple/sphinx-ext-autodoc
28 | Could not fetch URL http://pypi.python.org/simple/sphinx-ext-autodoc: HTTP Error 404: Not Found
29 | Will skip URL http://pypi.python.org/simple/sphinx-ext-autodoc when looking for download links for sphinx-ext-autodoc
30 | Getting page http://pypi.python.org/simple/
31 | Exception:
32 | Traceback (most recent call last):
33 | File "/Library/Python/2.6/site-packages/pip.py", line 274, in main
34 | self.run(options, args)
35 | File "/Library/Python/2.6/site-packages/pip.py", line 431, in run
36 | requirement_set.install_files(finder, force_root_egg_info=self.bundle)
37 | File "/Library/Python/2.6/site-packages/pip.py", line 1813, in install_files
38 | url = finder.find_requirement(req_to_install, upgrade=self.upgrade)
39 | File "/Library/Python/2.6/site-packages/pip.py", line 1044, in find_requirement
40 | url_name = self._find_url_name(Link(self.index_urls[0]), url_name, req) or req.url_name
41 | File "/Library/Python/2.6/site-packages/pip.py", line 1132, in _find_url_name
42 | for link in page.links:
43 | File "/Library/Python/2.6/site-packages/pip.py", line 2285, in links
44 | url = self.clean_link(urlparse.urljoin(self.url, url))
45 | File "/Library/Python/2.6/site-packages/pip.py", line 2331, in clean_link
46 | lambda match: '%%%2x' % ord(match.group(0)), url)
47 | KeyboardInterrupt
48 |
--------------------------------------------------------------------------------
/locking/forms.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 | from django.core.exceptions import NON_FIELD_ERRORS
3 | from django.forms.models import ModelForm, modelform_factory
4 | from django.utils.timesince import timeuntil
5 |
6 | from locking.models import Lock
7 |
8 |
9 | def locking_form_factory(model, form=ModelForm, request=None, *args, **kwargs):
10 | """
11 | Since we no longer decorate or extend models as part of locking, this is
12 | the most reliable way to throw ValidationErrors for hard locks in the
13 | admin.
14 |
15 | How it works:
16 | - We override ModelAdmin.get_form in locking.admin.LockableAdminMixin
17 | so that it uses this function instead of modelform_factory().
18 | - We create a new class, dynamically, which extends `form` (passed via
19 | kwarg) and which performs the validation check in _post_clean()
20 | after calling the super().
21 | - We pass the new form class in place of the original to
22 | modelform_factory().
23 | """
24 | user = getattr(request, 'user', None)
25 |
26 | class locking_form(form):
27 |
28 | def _post_clean(self):
29 | super(locking_form, self)._post_clean()
30 | # We were not passed a user, so we have no way of telling who is
31 | # the owner of an object's lock; better not to raise
32 | # ValidationError in that case
33 | if not user:
34 | return
35 |
36 | # If this model doesn't have primary keys, don't continue
37 | if not self._meta.model._meta.pk:
38 | return
39 |
40 | # If there are already errors, no point checking lock since save
41 | # will be prevented
42 | if self.errors:
43 | return
44 |
45 | # If we don't have a saved object yet, it could not have a lock
46 | if not self.instance.pk:
47 | return
48 |
49 | try:
50 | lock = Lock.objects.get_lock_for_object(self.instance)
51 | except Lock.DoesNotExist:
52 | return
53 |
54 | # If either of these conditions are met, we don't have an error.
55 | # If we pass beyond this point, we have a validation error because
56 | # the object is locked by a user other than the current user.
57 | if not lock.is_locked or lock.is_locked_by(user):
58 | return
59 |
60 | try:
61 | raise forms.ValidationError((
62 | u"You cannot save this %(verbose_name)s because it is "
63 | u"locked by %(user)s. The lock will expire in "
64 | u"%(time_remaining)s if that user is idle.") % {
65 | 'verbose_name': self._meta.model._meta.verbose_name,
66 | 'user': lock.locked_by.get_full_name(),
67 | 'time_remaining': timeuntil(lock.lock_expiration_time),
68 | })
69 | except forms.ValidationError as e:
70 | self._update_errors({NON_FIELD_ERRORS: e.messages})
71 |
72 | locking_form.__name__ = form.__name__
73 | return locking_form
74 |
--------------------------------------------------------------------------------
/docs/design.rst:
--------------------------------------------------------------------------------
1 | =====================
2 | Design considerations
3 | =====================
4 |
5 | Pessimistic versus optimistic locking
6 | -------------------------------------
7 |
8 | Essentially, **optimistic concurrency control** will either give the user a warning or throw an exception whenever they try to overwrite a piece of content that has been updated since they last opened it for editing. **Pessimistic concurrency control** will actually lock the content for one specific user, so that nobody else can edit the content while he or she is working on it.
9 |
10 | An optimistic system is easier to implement, but has the disadvantage of only preventing *overwrites*, not the actual concurrent editing -- which can be a pretty frustrating experience and a time waster for editors. Actual locking, that is, pessimistic concurrency control, can be a bit tricky to implement. Locks can often stay closed indefinitely or longer than expected because
11 |
12 | * a user's browser crashes before he navigates away from the page
13 | * when a user leaves an edit screen open in a neglected tab
14 | * the user navigates to another website without first saving
15 |
16 | Any good locking system thus should be able to **unlock** the page even if the user navigates away from the website, but also has to implement **lock expiry** to handle the aforementioned edge cases. ``django-locking`` does both. In addition, it warns users when their lock is about to expire, so they can easily save their progress and edit the content again to initiate a new lock.
17 |
18 | A short overview of different locking implementations
19 | -----------------------------------------------------
20 |
21 | Soft locks make sure to avoid concurrent edits in the Django admin interface, and also provide an interface by which you can check programatically if a piece of content is currently locked and act accordingly. However, a **soft locking** mechanism doesn't actually raise any exception when trying to save locked content, it only stops the save from occuring in the front-end of the website.
22 |
23 | While soft locking may seem a little weird, it actually has benefits over tougher approaches to locking. E.g. if you operate a pub review website that allows users to update the pricing of beer at different establishments, you may want to prevent an editor from updating a pub review when somebody else is updating the page, but may nevertheless still want to allow visitors to the site to update the price of a pint of beer, even though ``beer_price`` is an attribute on the same ``PubReview`` model.
24 |
25 | However, sometimes, your application really does need to prevent the ``Model.save`` method from executing, and throw an exception when anybody except the person who initiated the lock tries to save. We'll call this **hard locking** In some cases, namely if other non-Django applications interface directly with your database, you might even want **database-level row locking**.
26 |
27 | Implementation in ``django-locking``
28 | ''''''''''''''''''''''''''''''''''''
29 |
30 | ``django-locking`` currently supports both soft and hard locks, see :doc:`api`. Database-level row locking might be added in the future, but is more difficult to get right, as the app has to ascertain that your database supports it and get around any quirks and caveats that might apply to each different database. E.g. on MySQL ``InnoDB`` tables do, but ``MyISAM`` tables don't; sqlite has no row-level locking whatsoever but PostgreSQL does.
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | PAPER =
8 | BUILDDIR = _build
9 |
10 | # Internal variables.
11 | PAPEROPT_a4 = -D latex_paper_size=a4
12 | PAPEROPT_letter = -D latex_paper_size=letter
13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
14 |
15 | .PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest
16 |
17 | help:
18 | @echo "Please use \`make ' where is one of"
19 | @echo " html to make standalone HTML files"
20 | @echo " dirhtml to make HTML files named index.html in directories"
21 | @echo " pickle to make pickle files"
22 | @echo " json to make JSON files"
23 | @echo " htmlhelp to make HTML files and a HTML help project"
24 | @echo " qthelp to make HTML files and a qthelp project"
25 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
26 | @echo " changes to make an overview of all changed/added/deprecated items"
27 | @echo " linkcheck to check all external links for integrity"
28 | @echo " doctest to run all doctests embedded in the documentation (if enabled)"
29 |
30 | clean:
31 | -rm -rf $(BUILDDIR)/*
32 |
33 | html:
34 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
35 | @echo
36 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
37 |
38 | dirhtml:
39 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
40 | @echo
41 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
42 |
43 | pickle:
44 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
45 | @echo
46 | @echo "Build finished; now you can process the pickle files."
47 |
48 | json:
49 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
50 | @echo
51 | @echo "Build finished; now you can process the JSON files."
52 |
53 | htmlhelp:
54 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
55 | @echo
56 | @echo "Build finished; now you can run HTML Help Workshop with the" \
57 | ".hhp project file in $(BUILDDIR)/htmlhelp."
58 |
59 | qthelp:
60 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
61 | @echo
62 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \
63 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
64 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-locking.qhcp"
65 | @echo "To view the help file:"
66 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-locking.qhc"
67 |
68 | latex:
69 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
70 | @echo
71 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
72 | @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \
73 | "run these through (pdf)latex."
74 |
75 | changes:
76 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
77 | @echo
78 | @echo "The overview file is in $(BUILDDIR)/changes."
79 |
80 | linkcheck:
81 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
82 | @echo
83 | @echo "Link check complete; look for any errors in the above output " \
84 | "or in $(BUILDDIR)/linkcheck/output.txt."
85 |
86 | doctest:
87 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
88 | @echo "Testing of doctests in the sources finished, look at the " \
89 | "results in $(BUILDDIR)/doctest/output.txt."
90 |
--------------------------------------------------------------------------------
/docs/_themes/static/nature.css_t:
--------------------------------------------------------------------------------
1 | /**
2 | * Sphinx stylesheet -- default theme
3 | * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4 | */
5 |
6 | @import url("basic.css");
7 |
8 | /* -- page layout ----------------------------------------------------------- */
9 |
10 | body {
11 | font-family: Arial, sans-serif;
12 | font-size: 100%;
13 | background-color: #111;
14 | color: #555;
15 | margin: 0;
16 | padding: 0;
17 | }
18 |
19 | div.documentwrapper {
20 | float: left;
21 | width: 100%;
22 | }
23 |
24 | div.bodywrapper {
25 | margin: 0 0 0 230px;
26 | }
27 |
28 | hr{
29 | border: 1px solid #B1B4B6;
30 | }
31 |
32 | div.document {
33 | background-color: #eee;
34 | }
35 |
36 | div.body {
37 | background-color: #ffffff;
38 | color: #3E4349;
39 | padding: 0 30px 30px 30px;
40 | font-size: 0.8em;
41 | }
42 |
43 | div.footer {
44 | color: #555;
45 | width: 100%;
46 | padding: 13px 0;
47 | text-align: center;
48 | font-size: 75%;
49 | }
50 |
51 | div.footer a {
52 | color: #444;
53 | text-decoration: underline;
54 | }
55 |
56 | div.related {
57 | background-color: #6BA81E;
58 | line-height: 32px;
59 | color: #fff;
60 | text-shadow: 0px 1px 0 #444;
61 | font-size: 0.80em;
62 | }
63 |
64 | div.related a {
65 | color: #E2F3CC;
66 | }
67 |
68 | div.sphinxsidebar {
69 | font-size: 0.75em;
70 | line-height: 1.5em;
71 | }
72 |
73 | div.sphinxsidebarwrapper{
74 | padding: 20px 0;
75 | }
76 |
77 | div.sphinxsidebar h3,
78 | div.sphinxsidebar h4 {
79 | font-family: Arial, sans-serif;
80 | color: #222;
81 | font-size: 1.2em;
82 | font-weight: normal;
83 | margin: 0;
84 | padding: 5px 10px;
85 | background-color: #ddd;
86 | text-shadow: 1px 1px 0 white
87 | }
88 |
89 | div.sphinxsidebar h4{
90 | font-size: 1.1em;
91 | }
92 |
93 | div.sphinxsidebar h3 a {
94 | color: #444;
95 | }
96 |
97 |
98 | div.sphinxsidebar p {
99 | color: #888;
100 | padding: 5px 20px;
101 | }
102 |
103 | div.sphinxsidebar p.topless {
104 | }
105 |
106 | div.sphinxsidebar ul {
107 | margin: 10px 20px;
108 | padding: 0;
109 | color: #000;
110 | }
111 |
112 | div.sphinxsidebar a {
113 | color: #444;
114 | }
115 |
116 | div.sphinxsidebar input {
117 | border: 1px solid #ccc;
118 | font-family: sans-serif;
119 | font-size: 1em;
120 | }
121 |
122 | div.sphinxsidebar input[type=text]{
123 | margin-left: 20px;
124 | }
125 |
126 | /* -- body styles ----------------------------------------------------------- */
127 |
128 | a {
129 | color: #005B81;
130 | text-decoration: none;
131 | }
132 |
133 | a:hover {
134 | color: #E32E00;
135 | text-decoration: underline;
136 | }
137 |
138 | div.body h1,
139 | div.body h2,
140 | div.body h3,
141 | div.body h4,
142 | div.body h5,
143 | div.body h6 {
144 | font-family: Arial, sans-serif;
145 | background-color: #BED4EB;
146 | font-weight: normal;
147 | color: #212224;
148 | margin: 30px 0px 10px 0px;
149 | padding: 5px 0 5px 10px;
150 | text-shadow: 0px 1px 0 white
151 | }
152 |
153 | div.body h1 { border-top: 20px solid white; margin-top: 0; font-size: 200%; }
154 | div.body h2 { font-size: 150%; background-color: #C8D5E3; }
155 | div.body h3 { font-size: 120%; background-color: #D8DEE3; }
156 | div.body h4 { font-size: 110%; background-color: #D8DEE3; }
157 | div.body h5 { font-size: 100%; background-color: #D8DEE3; }
158 | div.body h6 { font-size: 100%; background-color: #D8DEE3; }
159 |
160 | a.headerlink {
161 | color: #c60f0f;
162 | font-size: 0.8em;
163 | padding: 0 4px 0 4px;
164 | text-decoration: none;
165 | }
166 |
167 | a.headerlink:hover {
168 | background-color: #c60f0f;
169 | color: white;
170 | }
171 |
172 | div.body p, div.body dd, div.body li {
173 | line-height: 1.5em;
174 | }
175 |
176 | div.admonition p.admonition-title + p {
177 | display: inline;
178 | }
179 |
180 | div.highlight{
181 | background-color: white;
182 | }
183 |
184 | div.note {
185 | background-color: #eee;
186 | border: 1px solid #ccc;
187 | }
188 |
189 | div.seealso {
190 | background-color: #ffc;
191 | border: 1px solid #ff6;
192 | }
193 |
194 | div.topic {
195 | background-color: #eee;
196 | }
197 |
198 | div.warning {
199 | background-color: #ffe4e4;
200 | border: 1px solid #f66;
201 | }
202 |
203 | p.admonition-title {
204 | display: inline;
205 | }
206 |
207 | p.admonition-title:after {
208 | content: ":";
209 | }
210 |
211 | pre {
212 | padding: 10px;
213 | background-color: White;
214 | color: #222;
215 | line-height: 1.2em;
216 | border: 1px solid #C6C9CB;
217 | font-size: 1.2em;
218 | margin: 1.5em 0 1.5em 0;
219 | -webkit-box-shadow: 1px 1px 1px #d8d8d8;
220 | -moz-box-shadow: 1px 1px 1px #d8d8d8;
221 | }
222 |
223 | tt {
224 | background-color: #ecf0f3;
225 | color: #222;
226 | padding: 1px 2px;
227 | font-size: 1.2em;
228 | font-family: monospace;
229 | }
230 |
--------------------------------------------------------------------------------
/docs/_themes/nature/static/nature.css_t:
--------------------------------------------------------------------------------
1 | /**
2 | * Sphinx stylesheet -- default theme
3 | * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4 | */
5 |
6 | @import url("basic.css");
7 |
8 | /* -- page layout ----------------------------------------------------------- */
9 |
10 | body {
11 | font-family: Arial, sans-serif;
12 | font-size: 120%;
13 | background-color: #111;
14 | color: #555;
15 | margin: 0;
16 | padding: 0;
17 | }
18 |
19 | div.documentwrapper {
20 | float: left;
21 | width: 100%;
22 | }
23 |
24 | div.bodywrapper {
25 | margin: 0 0 0 230px;
26 | }
27 |
28 | hr{
29 | border: 1px solid #B1B4B6;
30 | }
31 |
32 | div.document {
33 | background-color: #eee;
34 | }
35 |
36 | div.body {
37 | background-color: #ffffff;
38 | color: #3E4349;
39 | padding: 0 30px 30px 30px;
40 | font-size: 0.8em;
41 | }
42 |
43 | div.footer {
44 | color: #555;
45 | width: 100%;
46 | padding: 13px 0;
47 | text-align: center;
48 | font-size: 75%;
49 | }
50 |
51 | div.footer a {
52 | color: #444;
53 | text-decoration: underline;
54 | }
55 |
56 | div.related {
57 | background-color: #6BA81E;
58 | line-height: 32px;
59 | color: #fff;
60 | text-shadow: 0px 1px 0 #444;
61 | font-size: 0.80em;
62 | }
63 |
64 | div.related a {
65 | color: #E2F3CC;
66 | }
67 |
68 | div.sphinxsidebar {
69 | font-size: 0.75em;
70 | line-height: 1.5em;
71 | }
72 |
73 | div.sphinxsidebarwrapper{
74 | padding: 20px 0;
75 | }
76 |
77 | div.sphinxsidebar h3,
78 | div.sphinxsidebar h4 {
79 | font-family: Arial, sans-serif;
80 | color: #222;
81 | font-size: 1.2em;
82 | font-weight: normal;
83 | margin: 0;
84 | padding: 5px 10px;
85 | background-color: #ddd;
86 | text-shadow: 1px 1px 0 white
87 | }
88 |
89 | div.sphinxsidebar h4{
90 | font-size: 1.1em;
91 | }
92 |
93 | div.sphinxsidebar h3 a {
94 | color: #444;
95 | }
96 |
97 |
98 | div.sphinxsidebar p {
99 | color: #888;
100 | padding: 5px 20px;
101 | }
102 |
103 | div.sphinxsidebar p.topless {
104 | }
105 |
106 | div.sphinxsidebar ul {
107 | margin: 10px 20px;
108 | padding: 0;
109 | color: #000;
110 | }
111 |
112 | div.sphinxsidebar a {
113 | color: #444;
114 | }
115 |
116 | div.sphinxsidebar input {
117 | border: 1px solid #ccc;
118 | font-family: sans-serif;
119 | font-size: 1em;
120 | }
121 |
122 | div.sphinxsidebar input[type=text]{
123 | margin-left: 20px;
124 | }
125 |
126 | /* -- body styles ----------------------------------------------------------- */
127 |
128 | a {
129 | color: #005B81;
130 | text-decoration: none;
131 | }
132 |
133 | a:hover {
134 | color: #E32E00;
135 | text-decoration: underline;
136 | }
137 |
138 | div.body h1,
139 | div.body h2,
140 | div.body h3,
141 | div.body h4,
142 | div.body h5,
143 | div.body h6 {
144 | font-family: Arial, sans-serif;
145 | background-color: #BED4EB;
146 | font-weight: normal;
147 | color: #212224;
148 | margin: 30px 0px 10px 0px;
149 | padding: 5px 0 5px 10px;
150 | text-shadow: 0px 1px 0 white
151 | }
152 |
153 | div.body h1 { border-top: 20px solid white; margin-top: 0; font-size: 200%; }
154 | div.body h2 { font-size: 150%; background-color: #C8D5E3; }
155 | div.body h3 { font-size: 120%; background-color: #D8DEE3; }
156 | div.body h4 { font-size: 110%; background-color: #D8DEE3; }
157 | div.body h5 { font-size: 100%; background-color: #D8DEE3; }
158 | div.body h6 { font-size: 100%; background-color: #D8DEE3; }
159 |
160 | a.headerlink {
161 | color: #c60f0f;
162 | font-size: 0.8em;
163 | padding: 0 4px 0 4px;
164 | text-decoration: none;
165 | }
166 |
167 | a.headerlink:hover {
168 | background-color: #c60f0f;
169 | color: white;
170 | }
171 |
172 | div.body p, div.body dd, div.body li {
173 | line-height: 1.5em;
174 | }
175 |
176 | div.admonition p.admonition-title + p {
177 | display: inline;
178 | }
179 |
180 | div.highlight{
181 | background-color: white;
182 | }
183 |
184 | div.note {
185 | background-color: #eee;
186 | border: 1px solid #ccc;
187 | }
188 |
189 | div.seealso {
190 | background-color: #ffc;
191 | border: 1px solid #ff6;
192 | }
193 |
194 | div.topic {
195 | background-color: #eee;
196 | }
197 |
198 | div.warning {
199 | background-color: #ffe4e4;
200 | border: 1px solid #f66;
201 | }
202 |
203 | p.admonition-title {
204 | display: inline;
205 | }
206 |
207 | p.admonition-title:after {
208 | content: ":";
209 | }
210 |
211 | pre {
212 | padding: 10px;
213 | background-color: White;
214 | color: #222;
215 | line-height: 1.2em;
216 | border: 1px solid #C6C9CB;
217 | font-size: 1.2em;
218 | margin: 1.5em 0 1.5em 0;
219 | -webkit-box-shadow: 1px 1px 1px #d8d8d8;
220 | -moz-box-shadow: 1px 1px 1px #d8d8d8;
221 | }
222 |
223 | tt {
224 | background-color: #ecf0f3;
225 | color: #222;
226 | padding: 1px 2px;
227 | font-size: 1.2em;
228 | font-family: monospace;
229 | }
230 |
--------------------------------------------------------------------------------
/locking/south_migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | import datetime
3 | from south.db import db
4 | from south.v2 import SchemaMigration
5 | from django.db import models
6 |
7 | class Migration(SchemaMigration):
8 | def forwards(self, orm):
9 |
10 | # Adding model 'Lock'
11 | db.create_table('locking_lock', (
12 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
13 | ('_locked_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_column='locked_at')),
14 | ('app', self.gf('django.db.models.fields.CharField')(max_length=255, null=True)),
15 | ('model', self.gf('django.db.models.fields.CharField')(max_length=255, null=True)),
16 | ('entry_id', self.gf('django.db.models.fields.PositiveIntegerField')(db_index=True)),
17 | ('_locked_by', self.gf('django.db.models.fields.related.ForeignKey')(related_name='working_on_locking_lock', null=True, db_column='locked_by', to=orm['auth.User'])),
18 | ('_hard_lock', self.gf('django.db.models.fields.BooleanField')(default=False, db_column='hard_lock', blank=True)),
19 | ))
20 | db.send_create_signal('locking', ['Lock'])
21 |
22 | def backwards(self, orm):
23 |
24 | # Deleting model 'Lock'
25 | db.delete_table('locking_lock')
26 |
27 | models = {
28 | 'auth.group': {
29 | 'Meta': {'object_name': 'Group'},
30 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
31 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
32 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
33 | },
34 | 'auth.permission': {
35 | 'Meta': {'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
36 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
37 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
38 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
39 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
40 | },
41 | 'auth.user': {
42 | 'Meta': {'object_name': 'User'},
43 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
44 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
45 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
46 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
47 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
48 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}),
49 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
50 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
51 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
52 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
53 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
54 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
55 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
56 | },
57 | 'contenttypes.contenttype': {
58 | 'Meta': {'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
59 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
60 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
61 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
62 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
63 | },
64 | 'locking.lock': {
65 | 'Meta': {'object_name': 'Lock'},
66 | '_hard_lock': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'db_column': "'hard_lock'", 'blank': 'True'}),
67 | '_locked_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_column': "'locked_at'"}),
68 | '_locked_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'working_on_locking_lock'", 'null': 'True', 'db_column': "'locked_by'", 'to': "orm['auth.User']"}),
69 | 'app': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'}),
70 | 'entry_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
71 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
72 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'})
73 | }
74 | }
75 | complete_apps = ['locking']
76 |
--------------------------------------------------------------------------------
/locking/views.py:
--------------------------------------------------------------------------------
1 | """
2 | These views are called from javascript to open and close assets (objects), in order
3 | to prevent concurrent editing.
4 | """
5 | import json
6 | import textwrap
7 |
8 | from django.core.exceptions import ObjectDoesNotExist
9 | from django.core.urlresolvers import reverse
10 | from django.http import HttpResponse
11 | from django.contrib.contenttypes.models import ContentType
12 |
13 | from .utils import timedelta_to_seconds
14 | from .models import Lock, ObjectLockedError
15 | from . import settings as locking_settings
16 |
17 |
18 | json_encode = json.JSONEncoder(indent=4).encode
19 |
20 |
21 | def lock(model_admin, request, object_id, extra_context=None):
22 | existing_lock_pk = request.GET.get('lock_pk')
23 | ct = ContentType.objects.get_for_model(model_admin.model)
24 | try:
25 | lock = Lock.objects.get(content_type=ct, object_id=object_id)
26 | except Lock.DoesNotExist:
27 | try:
28 | ct.get_object_for_this_type(pk=object_id)
29 | except ObjectDoesNotExist:
30 | lock = None
31 | else:
32 | if not existing_lock_pk:
33 | lock = Lock(content_type=ct, object_id=object_id)
34 |
35 | if lock is None:
36 | if existing_lock_pk:
37 | status = 403
38 | else:
39 | status = 404
40 | else:
41 | try:
42 | lock.lock_for(request.user)
43 | except ObjectLockedError:
44 | status = 423 # HTTP 423 = 'Locked'
45 | else:
46 | status = 200
47 | lock.save()
48 | return render_lock_status(request, lock=lock, status=status)
49 |
50 |
51 | def _unlock(model_admin, request, object_id, extra_context=None, filter_user=False):
52 | ct = ContentType.objects.get_for_model(model_admin.model)
53 | filter_kwargs = {}
54 | if filter_user:
55 | filter_kwargs['_locked_by'] = request.user
56 | override = False
57 | else:
58 | override = True
59 | try:
60 | lock = Lock.objects.get(content_type=ct, object_id=object_id, **filter_kwargs)
61 | except Lock.DoesNotExist:
62 | return HttpResponse(status=404)
63 | else:
64 | try:
65 | lock.unlock_for(request.user, override=override)
66 | except ObjectLockedError:
67 | return HttpResponse(status=423)
68 | lock.save()
69 | return HttpResponse(status=200)
70 |
71 |
72 | def lock_remove(model_admin, request, object_id, extra_context=None):
73 | """Remove any lock on object_id"""
74 | return _unlock(model_admin, request, object_id, extra_context=extra_context)
75 |
76 |
77 | def lock_clear(model_admin, request, object_id, extra_context=None):
78 | """Clear any locks on object_id locked by the current user"""
79 | return _unlock(model_admin, request, object_id, extra_context=extra_context, filter_user=True)
80 |
81 |
82 | def render_lock_status(request, lock=None, status=200):
83 | data = {
84 | 'is_active': False,
85 | 'applies': False,
86 | 'for_user': None,
87 | }
88 | if lock:
89 | if not lock.locked_by:
90 | locked_by_name = None
91 | else:
92 | locked_by_name = lock.locked_by.get_full_name()
93 | if locked_by_name:
94 | locked_by_name = u"%(username)s (%(fullname)s)" % {
95 | 'username': lock.locked_by.username,
96 | 'fullname': locked_by_name,
97 | }
98 | else:
99 | locked_by_name = lock.locked_by.username
100 | data.update({
101 | 'lock_pk': lock.pk,
102 | 'current_user': getattr(request.user, 'username', None),
103 | 'is_active': lock.is_locked,
104 | 'locked_by': getattr(lock.locked_by, 'username', None),
105 | 'locked_by_name': locked_by_name,
106 | 'applies': lock.lock_applies_to(request.user),
107 | })
108 | return HttpResponse(json_encode(data), content_type='application/json', status=status)
109 |
110 |
111 | def lock_status(model_admin, request, object_id, extra_context=None, **kwargs):
112 | ct = ContentType.objects.get_for_model(model_admin.model)
113 | try:
114 | lock = Lock.objects.get(content_type=ct, object_id=object_id)
115 | except Lock.DoesNotExist:
116 | lock = None
117 | return render_lock_status(request, lock, **kwargs)
118 |
119 |
120 | def locking_js(model_admin, request, object_id, extra_context=None):
121 | opts = model_admin.model._meta
122 | info = (opts.app_label, opts.object_name.lower())
123 |
124 | locking_urls = {
125 | "lock_remove": reverse("admin:%s_%s_lock_remove" % info, args=[object_id]),
126 | }
127 |
128 | if object_id and object_id != '0':
129 | locking_urls.update({
130 | "lock": reverse("admin:%s_%s_lock" % info, args=[object_id]),
131 | "lock_clear": reverse("admin:%s_%s_lock_clear" % info, args=[object_id]),
132 | "lock_status": reverse("admin:%s_%s_lock_status" % info, args=[object_id]),
133 | })
134 |
135 | js_vars = {
136 | 'urls': locking_urls,
137 | 'time_until_expiration': timedelta_to_seconds(
138 | locking_settings.TIME_UNTIL_EXPIRATION),
139 | 'time_until_warning': timedelta_to_seconds(
140 | locking_settings.TIME_UNTIL_WARNING),
141 | }
142 |
143 | response_js = textwrap.dedent("""
144 | var DJANGO_LOCKING = (typeof window.DJANGO_LOCKING != 'undefined')
145 | ? DJANGO_LOCKING : {{}};
146 | DJANGO_LOCKING.config = {config_data}
147 | """).strip().format(config_data=json_encode(js_vars))
148 | return HttpResponse(response_js, content_type='application/x-javascript')
149 |
--------------------------------------------------------------------------------
/locking/south_migrations/0002_auto__del_field_lock_app__del_field_lock_entry_id__del_field_lock_mode.py:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 | import datetime
3 | from south.db import db
4 | from south.v2 import SchemaMigration
5 | from django.db import models
6 |
7 | class Migration(SchemaMigration):
8 |
9 | def forwards(self, orm):
10 |
11 | # Deleting field 'Lock.app'
12 | db.delete_column('locking_lock', 'app')
13 |
14 | # Deleting field 'Lock.entry_id'
15 | db.delete_column('locking_lock', 'entry_id')
16 |
17 | # Deleting field 'Lock.model'
18 | db.delete_column('locking_lock', 'model')
19 |
20 | # Adding field 'Lock.object_id'
21 | db.add_column('locking_lock', 'object_id', self.gf('django.db.models.fields.PositiveIntegerField')(default=0), keep_default=False)
22 |
23 | # Adding field 'Lock.content_type'
24 | db.add_column('locking_lock', 'content_type', self.gf('django.db.models.fields.related.ForeignKey')(default=0, to=orm['contenttypes.ContentType']), keep_default=False)
25 |
26 |
27 | def backwards(self, orm):
28 |
29 | # Adding field 'Lock.app'
30 | db.add_column('locking_lock', 'app', self.gf('django.db.models.fields.CharField')(max_length=255, null=True), keep_default=False)
31 |
32 | # Adding field 'Lock.entry_id'
33 | db.add_column('locking_lock', 'entry_id', self.gf('django.db.models.fields.PositiveIntegerField')(default=-1, db_index=True), keep_default=False)
34 |
35 | # Adding field 'Lock.model'
36 | db.add_column('locking_lock', 'model', self.gf('django.db.models.fields.CharField')(max_length=255, null=True), keep_default=False)
37 |
38 | # Deleting field 'Lock.object_id'
39 | db.delete_column('locking_lock', 'object_id')
40 |
41 | # Deleting field 'Lock.content_type'
42 | db.delete_column('locking_lock', 'content_type_id')
43 |
44 |
45 | models = {
46 | 'auth.group': {
47 | 'Meta': {'object_name': 'Group'},
48 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
49 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
50 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
51 | },
52 | 'auth.permission': {
53 | 'Meta': {'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
54 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
55 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
56 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
57 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
58 | },
59 | 'auth.user': {
60 | 'Meta': {'object_name': 'User'},
61 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2013, 2, 23, 14, 21, 4, 303732)'}),
62 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
63 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
64 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
65 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
66 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'blank': 'True'}),
67 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
68 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'blank': 'True'}),
69 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2013, 2, 23, 14, 21, 4, 303516)'}),
70 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
71 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
72 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
73 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
74 | },
75 | 'contenttypes.contenttype': {
76 | 'Meta': {'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
77 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
78 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
79 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
80 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
81 | },
82 | 'locking.lock': {
83 | 'Meta': {'object_name': 'Lock'},
84 | '_hard_lock': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'db_column': "'hard_lock'", 'blank': 'True'}),
85 | '_locked_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_column': "'locked_at'"}),
86 | '_locked_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'working_on_locking_lock'", 'null': 'True', 'db_column': "'locked_by'", 'to': "orm['auth.User']"}),
87 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
88 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
89 | 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {})
90 | }
91 | }
92 |
93 | complete_apps = ['locking']
94 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | =======================================
2 | Concurrency control with django-locking
3 | =======================================
4 |
5 | ``django-locking`` makes sure no two users can edit the same content at the same time, preventing annoying overwrites and lost time. Find the repository and download the code at http://github.com/stdbrouw/django-locking
6 |
7 | ``django-locking`` has only been tested on Django 1.2 and 1.3, but probably works from 1.0 onwards.
8 |
9 | The low-down
10 | ------------
11 |
12 | Django has seen great adoption in the content management sphere, especially among the newspaper crowd. One of the trickier things to get right, is to make sure that nobody steps on each other's toes while editing and modifying existing content. Newspaper editors might not always be aware of what other editors are up to, and this goes double for distributed teams. When different people work on the same content, the one who saves last will win the day, while the other edits are overwritten.
13 |
14 | ``django-locking`` **provides a system that makes concurrent editing impossible, and informs users of what other users are working on and for how long that content will remain locked. Users can still read locked content, but cannot modify or save it.**
15 |
16 | .. image:: screenshots/locked-list.png
17 |
18 | ``django-locking`` **interfaces with the django admin** application, but **also provides an API** that you can use in applications of your own.
19 |
20 | Table of contents
21 | -----------------
22 |
23 | You should take a look at this page first, as it'll answer most of your questions, but here's the TOC to the entire documentation:
24 |
25 | .. toctree::
26 | :glob:
27 |
28 | *
29 |
30 | Notes
31 | -----
32 |
33 | Looking for something else?
34 | '''''''''''''''''''''''''''
35 |
36 | Do note that, in the context of this application, 'locking' means preventing concurrent editing. You might also know this by the term 'mutex' or the more colloquial 'checkouts'. If you ended up here while looking for an application that provides permission-based access to certain content, read up on *row-level permissions* and *granular permissions*. Also check out django-lock__, django-granular-permissions__ and similar apps.
37 |
38 | .. __: http://code.google.com/p/django-lock/
39 |
40 | .. __: http://github.com/f4nt/django-granular-permissions
41 |
42 | Code quality
43 | ''''''''''''
44 |
45 | ``django-locking`` has seen a fair bit of production use and is well unit-tested. If you spot any bugs, please contact the author through the `GitHub issue tracker`__, or more directly `through GitHub or over e-mail`__.
46 |
47 | .. __: http://github.com/stdbrouw/django-locking/issues
48 |
49 | .. __: http://github.com/stdbrouw
50 |
51 | Features
52 | --------
53 |
54 | * admin integration
55 | * django-locking tells you right from the start if content is locked, rather than when you try to save and override somebody else's content
56 | * lock expiration: leaving a browser window open doesn't lock up content indefinitely
57 | * other users can still view locked content, they just can't edit the stuff somebody else is working on
58 | * configurable: you can define the amount of minutes before the app auto-expires content locks
59 | * users receive an alert when a lock is about to expire
60 |
61 | Some other things you might like to know about:
62 |
63 | * a choice between soft locks (only enforced at the front-end level) and hard locks (enforced at the ORM level -- raising an error when trying to save a locked object). (See :doc:`design`)
64 | * A public API for coders who want to integrate their apps with ``django-locking``. See :doc:`developers` and :doc:`api`.
65 | * well-documented
66 | * well-tested
67 | * verbose (i.e. a lot of logging to ``sys.stdout``), so you can see what's going on behind the screen
68 |
69 | For other stuff on the roadmap, see :doc:`ponies`.
70 |
71 | Installation
72 | ------------
73 |
74 | #. This app will not be available on PyPI until version 0.3 at the earliest. In the meanwhile, just download the package and install it using ``python setup.py install``.
75 | #. Add ``locking`` to your ``INSTALLED_APPS`` in the ``settings.py`` to your project.
76 | #. You may optionally specify a ``LOCK_TIMEOUT`` in ``settings.py``, which should be in seconds. It defaults to half an hour (1800 seconds).
77 | #. Configure your development environment for file serving using ``django-staticfiles``. See the documentation here__.
78 | #. Add ``(r'^ajax/admin/', include('locking.urls'))`` to your urlconf (``urls.py``). You may use any base url, ``ajax/admin/`` is just an example.
79 | #. Specify ``locking.models.LockableModel`` as a base class for any model that requires locking. If you're doing this on an existing model, be aware that ``syncdb`` won't work -- you'll either need South or do the migration manually. (``syncdb`` doesn't add new fields to any existing table.)
80 | #. To enable locking in the admin interface, specify ``locking.admin.LockableAdmin`` as the base class for your own ModelAdmins.
81 |
82 | .. __: http://bitbucket.org/jezdez/django-staticfiles/src#serving-static-files-during-development
83 |
84 | Want to know more about the public API? :doc:`api`
85 |
86 | Something not working? Contact `the author`__ or `open an issue on GitHub`__.
87 |
88 | .. __: http://github.com/stdbrouw
89 |
90 | .. __: http://github.com/stdbrouw/django-locking/issues
91 |
92 | Usage
93 | -----
94 |
95 | Once you've installed ``django-locking`` and have one or a few models that have ``locking.models.LockableModel`` as a base class, and ModelAdmins that have ``locking.admin.LockableAdmin`` as a base class, you're good to go.
96 |
97 | .. image:: screenshots/locked-editscreen.png
98 |
99 | ``django-locking`` enables locking in the admin by disabling all input fields. That way, any user can still read locked content, they just can't edit it.
100 |
101 | * A lock icon indicates locked content in the list edit screen
102 | * A red warning message indicates locked content on the edit page itself.
103 | * Five minutes before the lock times out, users will receive a javascript alert with a message warning them to save their content before they lose their edit lock.
104 |
105 | Advanced usage
106 | --------------
107 |
108 | * By default, ``django-locking`` uses **soft locks**. Read more about different methods of locking over at :doc:`design`.
109 | * When integrating with your own applications, you should take care when overriding certain methods, specifically ``LockableModel.save``, ``LockableAdmin.changelist_view`` and ``LockableAdmin.save_model``, as well as any of the methods that come with ``django-locking`` itself (see :doc:`api`). Make sure to call ``super`` if you want to maintain the default behavior of ``django-locking``.
110 |
111 | Learn more about best practices when using super here__. Chiefly, do not assume that subclasses won't need or superclasses won't pass any extra arguments. You will want your overrides to look like this:
112 |
113 | .. __: http://fuhm.net/super-harmful/
114 |
115 | ::
116 |
117 | def save(*vargs, **kwargs):
118 | super(self.__class__, self).save(*vargs, **kwargs)
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # django-locking documentation build configuration file, created by
4 | # sphinx-quickstart on Fri May 28 11:02:44 2010.
5 | #
6 | # This file is execfile()d with the current directory set to its containing dir.
7 | #
8 | # Note that not all possible configuration values are present in this
9 | # autogenerated file.
10 | #
11 | # All configuration values have a default; values that are commented out
12 | # serve to show the default.
13 |
14 | import sys, os
15 | os.environ['DJANGO_SETTINGS_MODULE'] = 'django.conf.global_settings'
16 |
17 | # If extensions (or modules to document with autodoc) are in another directory,
18 | # add these directories to sys.path here. If the directory is relative to the
19 | # documentation root, use os.path.abspath to make it absolute, like shown here.
20 | #sys.path.append(os.path.abspath('.'))
21 |
22 | # -- General configuration -----------------------------------------------------
23 |
24 | # Add any Sphinx extension module names here, as strings. They can be extensions
25 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
26 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.coverage']
27 |
28 | # Add any paths that contain templates here, relative to this directory.
29 | templates_path = ['_templates']
30 |
31 | # The suffix of source filenames.
32 | source_suffix = '.rst'
33 |
34 | # The encoding of source files.
35 | #source_encoding = 'utf-8'
36 |
37 | # The master toctree document.
38 | master_doc = 'index'
39 |
40 | # General information about the project.
41 | project = u'django-locking'
42 | copyright = u'2010, Stijn Debrouwere'
43 |
44 | # The version info for the project you're documenting, acts as replacement for
45 | # |version| and |release|, also used in various other places throughout the
46 | # built documents.
47 | #
48 | # The short X.Y version.
49 | version = '0.3'
50 | # The full version, including alpha/beta/rc tags.
51 | release = '0.3'
52 |
53 | # The language for content autogenerated by Sphinx. Refer to documentation
54 | # for a list of supported languages.
55 | #language = None
56 |
57 | # There are two options for replacing |today|: either, you set today to some
58 | # non-false value, then it is used:
59 | #today = ''
60 | # Else, today_fmt is used as the format for a strftime call.
61 | #today_fmt = '%B %d, %Y'
62 |
63 | # List of documents that shouldn't be included in the build.
64 | #unused_docs = []
65 |
66 | # List of directories, relative to source directory, that shouldn't be searched
67 | # for source files.
68 | exclude_trees = ['_build']
69 |
70 | # The reST default role (used for this markup: `text`) to use for all documents.
71 | #default_role = None
72 |
73 | # If true, '()' will be appended to :func: etc. cross-reference text.
74 | #add_function_parentheses = True
75 |
76 | # If true, the current module name will be prepended to all description
77 | # unit titles (such as .. function::).
78 | #add_module_names = True
79 |
80 | # If true, sectionauthor and moduleauthor directives will be shown in the
81 | # output. They are ignored by default.
82 | #show_authors = False
83 |
84 | # The name of the Pygments (syntax highlighting) style to use.
85 | pygments_style = 'tango'
86 |
87 | # A list of ignored prefixes for module index sorting.
88 | #modindex_common_prefix = []
89 |
90 |
91 | # -- Options for HTML output ---------------------------------------------------
92 |
93 | # The theme to use for HTML and HTML Help pages. Major themes that come with
94 | # Sphinx are currently 'default' and 'sphinxdoc'.
95 | html_theme = 'nature'
96 |
97 | # Theme options are theme-specific and customize the look and feel of a theme
98 | # further. For a list of options available for each theme, see the
99 | # documentation.
100 | #html_theme_options = {}
101 |
102 | # Add any paths that contain custom themes here, relative to this directory.
103 | html_theme_path = ['_themes']
104 |
105 | # The name for this set of Sphinx documents. If None, it defaults to
106 | # " v documentation".
107 | #html_title = None
108 |
109 | # A shorter title for the navigation bar. Default is the same as html_title.
110 | #html_short_title = None
111 |
112 | # The name of an image file (relative to this directory) to place at the top
113 | # of the sidebar.
114 | #html_logo = None
115 |
116 | # The name of an image file (within the static path) to use as favicon of the
117 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
118 | # pixels large.
119 | #html_favicon = None
120 |
121 | # Add any paths that contain custom static files (such as style sheets) here,
122 | # relative to this directory. They are copied after the builtin static files,
123 | # so a file named "default.css" will overwrite the builtin "default.css".
124 | html_static_path = ['_static']
125 |
126 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
127 | # using the given strftime format.
128 | #html_last_updated_fmt = '%b %d, %Y'
129 |
130 | # If true, SmartyPants will be used to convert quotes and dashes to
131 | # typographically correct entities.
132 | #html_use_smartypants = True
133 |
134 | # Custom sidebar templates, maps document names to template names.
135 | #html_sidebars = {}
136 |
137 | # Additional templates that should be rendered to pages, maps page names to
138 | # template names.
139 | #html_additional_pages = {}
140 |
141 | # If false, no module index is generated.
142 | #html_use_modindex = True
143 |
144 | # If false, no index is generated.
145 | #html_use_index = True
146 |
147 | # If true, the index is split into individual pages for each letter.
148 | #html_split_index = False
149 |
150 | # If true, links to the reST sources are added to the pages.
151 | #html_show_sourcelink = True
152 |
153 | # If true, an OpenSearch description file will be output, and all pages will
154 | # contain a tag referring to it. The value of this option must be the
155 | # base URL from which the finished HTML is served.
156 | #html_use_opensearch = ''
157 |
158 | # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml").
159 | #html_file_suffix = ''
160 |
161 | # Output file base name for HTML help builder.
162 | htmlhelp_basename = 'django-lockingdoc'
163 |
164 |
165 | # -- Options for LaTeX output --------------------------------------------------
166 |
167 | # The paper size ('letter' or 'a4').
168 | #latex_paper_size = 'letter'
169 |
170 | # The font size ('10pt', '11pt' or '12pt').
171 | #latex_font_size = '10pt'
172 |
173 | # Grouping the document tree into LaTeX files. List of tuples
174 | # (source start file, target name, title, author, documentclass [howto/manual]).
175 | latex_documents = [
176 | ('index', 'django-locking.tex', u'django-locking Documentation',
177 | u'Stijn Debrouwere', 'manual'),
178 | ]
179 |
180 | # The name of an image file (relative to this directory) to place at the top of
181 | # the title page.
182 | #latex_logo = None
183 |
184 | # For "manual" documents, if this is true, then toplevel headings are parts,
185 | # not chapters.
186 | #latex_use_parts = False
187 |
188 | # Additional stuff for the LaTeX preamble.
189 | #latex_preamble = ''
190 |
191 | # Documents to append as an appendix to all manuals.
192 | #latex_appendices = []
193 |
194 | # If false, no module index is generated.
195 | #latex_use_modindex = True
196 |
--------------------------------------------------------------------------------
/locking/admin.py:
--------------------------------------------------------------------------------
1 | import functools
2 | import json
3 |
4 | try:
5 | from custom_admin import admin
6 | except ImportError:
7 | from django.contrib import admin
8 |
9 | import django
10 | from django import forms
11 | from django.core.urlresolvers import reverse
12 | from django.utils import html as html_utils
13 | from django.utils.functional import curry
14 | from django.utils.timesince import timeuntil
15 | from django.utils.translation import ugettext as _
16 | from django.utils.decorators import method_decorator
17 | from django.views.decorators.csrf import csrf_protect
18 |
19 | try:
20 | from django.template.response import TemplateResponse
21 | except ImportError:
22 | # django <= 1.2, not fully supported
23 | class TemplateResponse(object): pass
24 |
25 | from .models import Lock
26 | from .forms import locking_form_factory
27 | from . import settings as locking_settings, views as locking_views
28 |
29 |
30 | json_encode = json.JSONEncoder(indent=4).encode
31 |
32 | csrf_protect_m = method_decorator(csrf_protect)
33 |
34 |
35 | class LockableAdminMixin(object):
36 |
37 | @property
38 | def media(self):
39 | return super(LockableAdminMixin, self).media + forms.Media(**{
40 | 'js': (
41 | locking_settings.STATIC_URL + "locking/js/admin.locking.js?v=6",
42 | ),
43 | 'css': {
44 | 'all': (locking_settings.STATIC_URL + 'locking/css/locking.css',),
45 | }})
46 |
47 | def locking_media(self, obj=None):
48 | opts = self.model._meta
49 | info = (opts.app_label, getattr(opts, 'model_name', None) or getattr(opts, 'module_name', None))
50 | pk = getattr(obj, 'pk', None) or 0
51 | return forms.Media(js=(
52 | reverse('admin:%s_%s_lock_js' % info, args=[pk]),))
53 |
54 | def get_urls(self):
55 | """
56 | Appends locking urls to the ModelAdmin's own urls. Its url names
57 | are patterned after the urls for the ModelAdmin's views (e.g.
58 | changelist_view, change_view).
59 |
60 | The url names appended are:
61 |
62 | admin:%(app_label)s_%(object_name)s_lock
63 | admin:%(app_label)s_%(object_name)s_lock_clear
64 | admin:%(app_label)s_%(object_name)s_lock_remove
65 | admin:%(app_label)s_%(object_name)s_lock_status
66 | admin:%(app_label)s_%(object_name)s_lock_js
67 | """
68 | try:
69 | from django.conf.urls import url
70 | except ImportError:
71 | from django.conf.urls.defaults import url
72 |
73 | def wrap(view):
74 | curried_view = curry(view, self)
75 | def wrapper(*args, **kwargs):
76 | return self.admin_site.admin_view(curried_view)(*args, **kwargs)
77 | return functools.update_wrapper(wrapper, view)
78 |
79 | opts = self.model._meta
80 | info = (opts.app_label, getattr(opts, 'model_name', None) or getattr(opts, 'module_name', None))
81 |
82 | return [
83 | url(r'^(.+)/locking_variables\.js$',
84 | wrap(locking_views.locking_js),
85 | name="%s_%s_lock_js" % info),
86 | url(r'^(.+)/lock/$',
87 | wrap(locking_views.lock),
88 | name="%s_%s_lock" % info),
89 | url(r'^(.+)/lock_clear/$',
90 | wrap(locking_views.lock_clear),
91 | name="%s_%s_lock_clear" % info),
92 | url(r'^(.+)/lock_remove/$',
93 | wrap(locking_views.lock_remove),
94 | name="%s_%s_lock_remove" % info),
95 | url(r'^(.+)/lock_status/$',
96 | wrap(locking_views.lock_status),
97 | name="%s_%s_lock_status" % info),
98 | ] + super(LockableAdminMixin, self).get_urls()
99 |
100 | @csrf_protect_m
101 | def changelist_view(self, request, extra_context=None):
102 | """
103 | Append locking media to the changelist view.
104 |
105 | This method will not work properly on django <= 1.2
106 | """
107 | response = super(LockableAdminMixin, self).changelist_view(request, extra_context)
108 | if isinstance(response, TemplateResponse):
109 | try:
110 | response.context_data['media'] += self.locking_media()
111 | except KeyError:
112 | pass
113 | return response
114 |
115 | def render_change_form(self, request, context, add=False, obj=None, **kwargs):
116 | if not add and getattr(obj, 'pk', None):
117 | locking_media = self.locking_media(obj)
118 | if isinstance(context['media'], basestring):
119 | locking_media = unicode(locking_media)
120 | context['media'] += locking_media
121 | return super(LockableAdminMixin, self).render_change_form(
122 | request, context, add=add, obj=obj, **kwargs)
123 |
124 | def get_form(self, request, obj=None, **kwargs):
125 | kwargs['form'] = locking_form_factory(self.model, kwargs.get('form', self.form))
126 | return super(LockableAdminMixin, self).get_form(request, obj, **kwargs)
127 |
128 | def save_model(self, request, obj, *args, **kwargs):
129 | """
130 | Clears the lock owned by the current user, if it wasn't cleared on
131 | unload, then saves the admin model instance.
132 | """
133 | if getattr(obj, 'pk', None):
134 | try:
135 | lock = Lock.objects.get_lock_for_object(obj)
136 | except Lock.DoesNotExist:
137 | pass
138 | else:
139 | if lock.is_locked and lock.is_locked_by(request.user):
140 | lock.unlock_for(request.user)
141 | super(LockableAdminMixin, self).save_model(request, obj, *args, **kwargs)
142 |
143 | def get_queryset(self, request):
144 | """
145 | Extended queryset method which adds a custom SQL select column,
146 | `_locking_user_pk`, which is set to the pk of the current request's
147 | user instance. Doing this allows us to access the user id by
148 | obj._locking_user_pk for any object returned from this queryset.
149 | """
150 | if django.VERSION < (1, 7):
151 | qs = super(LockableAdminMixin, self).queryset(request)
152 | else:
153 | qs = super(LockableAdminMixin, self).get_queryset(request)
154 | return qs.extra(select={
155 | '_locking_user_pk': "%d" % request.user.pk,
156 | })
157 |
158 | if django.VERSION < (1, 7):
159 | queryset = get_queryset
160 |
161 | def get_lock_for_admin(self, obj):
162 | """
163 | Returns the locking status along with a nice icon for the admin
164 | interface use in admin list display like so:
165 | list_display = ['title', 'get_lock_for_admin']
166 | """
167 | current_user_id = obj._locking_user_pk
168 |
169 | try:
170 | lock = Lock.objects.get_lock_for_object(obj)
171 | except Lock.DoesNotExist:
172 | return u""
173 | else:
174 | if not lock.is_locked:
175 | return u""
176 |
177 | until = timeuntil(lock.lock_expiration_time)
178 |
179 | locked_by_name = lock.locked_by.get_full_name()
180 | if locked_by_name:
181 | locked_by_name = u"%(username)s (%(fullname)s)" % {
182 | 'username': lock.locked_by.username,
183 | 'fullname': locked_by_name,
184 | }
185 | else:
186 | locked_by_name = lock.locked_by.username
187 |
188 | if lock.locked_by.pk == current_user_id:
189 | msg = _(u"You own this lock for %s longer") % until
190 | css_class = 'locking-edit'
191 | else:
192 | msg = _(u"Locked by %s for %s longer") % (until, locked_by_name)
193 | css_class = 'locking-locked'
194 |
195 | return (
196 | u' '
200 | ) % {
201 | 'msg': html_utils.escape(msg),
202 | 'locked_obj_id': obj.pk,
203 | 'locked_by_name': html_utils.escape(locked_by_name),
204 | 'css_class': css_class,}
205 |
206 | get_lock_for_admin.allow_tags = True
207 | get_lock_for_admin.short_description = 'Lock'
208 |
209 |
210 | class LockableAdmin(LockableAdminMixin, admin.ModelAdmin):
211 | pass
212 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | =======================================
2 | Concurrency control with django-locking
3 | =======================================
4 |
5 | Django has seen great adoption in the content management sphere, especially among the newspaper crowd. One of the trickier things to get right, is to make sure that nobody steps on each others toes while editing and modifying existing content. Newspaper editors might not always be aware of what other editors are up to, and this goes double for distributed teams. When different people work on the same content, the one who saves last will win the day, while the other edits are overwritten.
6 |
7 | `django-locking` provides a system that makes concurrent editing impossible, and informs users of what other users are working on and for how long that content will remain locked. Users can still read locked content, but cannot modify or save it.
8 |
9 | ``django-locking`` makes sure no two users can edit the same content at the same time, preventing annoying overwrites and lost time. Find the repository and download the code at http://github.com/stdbrouw/django-locking
10 |
11 | ``django-locking`` has only been tested on Django 1.2 and 1.3, but probably works from 1.0 onwards.
12 |
13 | Documentation
14 | -------------
15 | Forked from the Django Locking plugin at stdbrouw/django-locking, this code features the cream of the crop for django-locking combining features from over 4 repos!
16 |
17 | New features added to this fork
18 | ===============================
19 | Changes on change list pages
20 | ----------------------------
21 |
22 | Unlock content object from change list page by simply clicking on the lock icon
23 | _______________________________________________________________________________
24 |
25 | 
26 |
27 | Hover over the lock icon to see when the lock expires
28 | _____________________________________________________
29 |
30 | 
31 |
32 | Hover over the username by the lock icon to see the full name of the person who has locked the content object
33 | _____________________________________________________________________________________________________________
34 |
35 | 
36 |
37 |
38 | Consolidated username and lock icon into one column on change list page
39 | Changes in settings:
40 | ----------------------------
41 |
42 | Added Lock warning and expiration flags in terms of seconds
43 |
44 | Lock messages:
45 | ----------------------------
46 |
47 | Added options to reload or save the object when lock expiration message is shown
48 |
49 | 
50 |
51 | Improved look and feel for the lock messages
52 | Lock messages fade in and out seamlessly
53 | Added much more detail to let users know who the content object was locked by providing the username, first name and last name
54 | Added lock expiration warnings
55 | Shows how much longer the object is locked for in minutes
56 |
57 | Locking:
58 | ----------------------------
59 |
60 | Added hard locking support using Django's validation framework
61 |
62 | 
63 |
64 | Set hard and soft locking as the default to ensure the integrity of locking
65 | Added seamless unlocking when lock expires
66 |
67 | 
68 |
69 |
70 | Architecture:
71 | ----------------------------
72 |
73 | 1 model tracks lock information and that's it! No messy migrations for each model that needs locking.
74 | Refactored and cleaned up code for easier maintainability
75 | Simplified installation by coupling common functionality into base admin/form/model classes
76 |
77 |
78 | 10 Minute Install
79 | -----------------
80 |
81 | 1) Get the code:
82 |
83 | git clone git@github.com:RobCombs/django-locking.git
84 |
85 | 2) Install the django-locking python egg:
86 |
87 | cd django-locking
88 | sudo python setup.py install
89 |
90 | 3) Add locking to the list of INSTALLED_APPS in project settings file:
91 |
92 | INSTALLED_APPS = ('locking',)
93 |
94 | 4) Add the following url mapping to your urls.py file:
95 |
96 | urlpatterns = patterns('',
97 | (r'^admin/ajax/', include('locking.urls')),
98 | )
99 |
100 | 5) Add locking to the admin files that you want locking for:
101 |
102 | from locking.admin import LockableAdmin
103 | class YourAdmin(LockableAdmin):
104 | list_display = ('get_lock_for_admin')
105 |
106 | 6) Add warning and expiration time outs to your Django settings file:
107 |
108 | LOCKING = {'time_until_expiration': 120, 'time_until_warning': 60}
109 |
110 |
111 | 7) Build the Lock table in the database:
112 |
113 | django-admin.py/manage.py migrate locking (For south users. Recommended approach) OR
114 | django-admin.py/manage.py syncdb (For non south users)
115 |
116 | 8) Install django-locking media:
117 |
118 | cp -r django-locking/locking/media/locking $your static media directory
119 |
120 | Note: This is the step where people usually get lost.
121 | Just start up your django server and look for the 200/304s http responses when the server attempts to load the media
122 | as you navigate to a model change list/view page where you've enabled django-locking. If you see 404s, you put the media in the wrong directory!
123 |
124 | You should see something like this in the django server console:
125 |
126 | [02/May/2012 15:33:20] "GET /media/static/locking/css/locking.css HTTP/1.1" 304 0
127 |
128 | [02/May/2012 15:33:20] "GET /media/static/web/common/javascript/jquery-1.4.4.min.js HTTP/1.1" 304 0
129 |
130 | [02/May/2012 15:33:20] "GET /media/static/locking/js/jquery.url.packed.js HTTP/1.1" 304 0
131 |
132 | [02/May/2012 15:33:21] "GET /admin/ajax/variables.js HTTP/1.1" 200 114
133 |
134 | [02/May/2012 15:33:21] "GET /media/static/locking/js/admin.locking.js?v=1 HTTP/1.1" 304 0
135 |
136 | [02/May/2012 15:33:21] "GET /admin/ajax/redirects/medleyobjectredirect/14/is_locked/?_=1335987201245 HTTP/1.1" 200 0
137 |
138 | [02/May/2012 15:33:21] "GET /admin/ajax/redirects/medleyobjectredirect/14/lock/?_=1335987201295 HTTP/1.1" 200 0
139 |
140 |
141 | You can also hit the media directly for troubleshooting your django-locking media installation:
142 | http://www.local.wsbradio.com:8000/media/static/locking/js/admin.locking.js
143 | If the url resolves, then you've completed this step correctly!
144 | Basically, the code refers to the media like so. That's why you needed to do this step.
145 |
146 | class Media:
147 | js = ( 'http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js',
148 | 'static/locking/js/jquery.url.packed.js',
149 | "/admin/ajax/variables.js",
150 | "static/locking/js/admin.locking.js?v=1")
151 | css = {"all": ("static/locking/css/locking.css",)
152 | }
153 |
154 | That's it!
155 |
156 | Checking the installation
157 | -------------------------
158 | Simulate a lock situation -> Open 2 browsers and hit your admin site with one user logged into the 1st browser and
159 | other user logged into the other. Go to the model in the admin that you've installed locking for with one browser.
160 | On the other browser, go to the change list/change view pages of the model that you've installed django-locking for.
161 | You'll see locks in the interface similar to the screen shots above.
162 |
163 | You can also look at your server console and you'll see the client making ajax calls to the django server checking for locks like so:
164 |
165 | [04/May/2012 15:15:09] "GET /admin/ajax/redirects/medleyobjectredirect/14/is_locked/?_=1336158909826 HTTP/1.1" 200 0
166 | [04/May/2012 15:15:09] "GET /admin/ajax/redirects/medleyobjectredirect/14/lock/?_=1336158909858 HTTP/1.1" 200 0
167 |
168 | Optional
169 | --------
170 | If you'd like to enforce hard locking(locking at the database level), then add the LockingForm class to the same admin pages
171 |
172 | Example:
173 |
174 | from locking.forms import LockingForm
175 | class YourAdmin(LockableAdmin):
176 | list_display = ('get_lock_for_admin')
177 | form = LockingForm
178 |
179 | Note: if you have an existing form and clean method, then call super to invoke the LockingForm's clean method
180 |
181 | Example:
182 |
183 | from locking.forms import LockingForm
184 | class YourFormForm(LockingForm):
185 | def clean(self):
186 | self.cleaned_data = super(MedleyRedirectForm, self).clean()
187 | ...some code
188 | return self.cleaned_data
189 |
190 | CREDIT
191 | ------
192 | This code is basically a composition of the following repos with a taste of detailed descretion from me. Credit goes out to the following authors and repos for their contributions
193 | and my job for funding this project:
194 | https://github.com/stdbrouw/django-locking
195 | https://github.com/runekaagaard/django-locking
196 | https://github.com/theatlantic/django-locking
197 | https://github.com/ortsed/django-locking
--------------------------------------------------------------------------------
/locking/models.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from datetime import datetime
3 |
4 | from django.db import models
5 |
6 | # Forward compat with Django 1.5's custom user models
7 | from django.conf import settings
8 | try:
9 | from django.contrib.auth import get_user_model
10 | except ImportError:
11 | from amc_ldap.utils import get_user_model
12 |
13 | from django.contrib.contenttypes.models import ContentType
14 | try:
15 | from django.contrib.contenttypes.fields import GenericRelation, GenericForeignKey
16 | except ImportError:
17 | from django.contrib.contenttypes.generic import GenericRelation, GenericForeignKey
18 |
19 | from . import managers, settings as locking_settings
20 | from .utils import timedelta_to_seconds
21 |
22 |
23 | logger = logging.getLogger('django.locker')
24 |
25 |
26 | class ObjectLockedError(IOError):
27 | pass
28 |
29 |
30 | class LockingManager(models.Manager):
31 |
32 | def get_lock_for_object(self, obj, filters=None):
33 | if not isinstance(obj, models.Model):
34 | raise TypeError((
35 | "%(fn)s() argument 1 must be %(expected_type)s, not "
36 | "%(actual_type)s") % {
37 | 'fn': 'get_lock_for_object',
38 | 'expected_type': "django.db.models.Model",
39 | 'actual_type': type(obj).__name__,})
40 | if not getattr(obj._meta, 'pk', None):
41 | raise Exception((
42 | u"Cannot get lock for instance %(instance)s; model "
43 | u"%(app_label)s.%(object_name)s has no primary key field") % {
44 | 'instance': unicode(obj),
45 | 'app_label': obj._meta.app_label,
46 | 'object_name': obj._meta.object_name,})
47 |
48 | if filters is None:
49 | # Check if the models define a GenericRelation to locking.Lock,
50 | # and if so use that. This allows the use of prefetch_related()
51 | # for locks in changelist views.
52 | generic_rels = [f for f in obj._meta.many_to_many
53 | if isinstance(f, GenericRelation)]
54 | try:
55 | locks_field = [f.name for f in generic_rels if f.rel.to == self.model][0]
56 | except IndexError:
57 | pass
58 | else:
59 | locks = getattr(obj, locks_field).all()
60 | try:
61 | return locks[0]
62 | except IndexError:
63 | raise self.model.DoesNotExist("Lock matching query does not exist.")
64 |
65 | filter_kwargs = {
66 | 'content_type': ContentType.objects.get_for_model(obj.__class__),
67 | 'object_id': obj.pk,
68 | }
69 | if filters:
70 | filter_kwargs.update(filters)
71 | return self.get(**filter_kwargs)
72 |
73 |
74 | class Lock(models.Model):
75 | """
76 | LockableModel comes with three managers: ``objects``, ``locked`` and
77 | ``unlocked``. They do what you'd expect them to.
78 | """
79 |
80 | def __init__(self, *vargs, **kwargs):
81 | super(Lock, self).__init__(*vargs, **kwargs)
82 | self._state.locking = False
83 |
84 | objects = LockingManager()
85 |
86 | locked = managers.LockedManager()
87 |
88 | unlocked = managers.UnlockedManager()
89 |
90 | content_type = models.ForeignKey(ContentType)
91 | object_id = models.PositiveIntegerField()
92 |
93 | content_object = GenericForeignKey('content_type', 'object_id')
94 |
95 | _locked_at = models.DateTimeField(db_column='locked_at',
96 | null=True,
97 | editable=False)
98 |
99 | _locked_by = models.ForeignKey(settings.AUTH_USER_MODEL,
100 | db_column='locked_by',
101 | related_name="working_on_%(app_label)s_%(class)s",
102 | null=True,
103 | editable=False)
104 |
105 | _hard_lock = models.BooleanField(db_column='hard_lock', default=False,
106 | editable=False)
107 |
108 | class Meta:
109 | ordering = ('-_locked_at',)
110 |
111 | # We don't want end-developers to manipulate database fields directly,
112 | # hence we're putting these behind simple getters.
113 | # End-developers should use functionality like the lock_for method instead.
114 | @property
115 | def locked_at(self):
116 | """A simple ``DateTimeField`` that is the heart of the locking
117 | mechanism. Read-only."""
118 | return self._locked_at
119 |
120 | @property
121 | def locked_by(self):
122 | """``locked_by`` is a foreign key to ``auth.User``.
123 | The ``related_name`` on the User object is ``working_on_%(app_label)s_%(class)s``.
124 | Read-only."""
125 | return self._locked_by
126 |
127 | @property
128 | def lock_type(self):
129 | """ Returns the type of lock that is currently active. Either
130 | ``hard``, ``soft`` or ``None``. Read-only. """
131 | if self.is_locked:
132 | if self._hard_lock:
133 | return "hard"
134 | else:
135 | return "soft"
136 | else:
137 | return None
138 |
139 | @property
140 | def is_locked(self):
141 | """
142 | A read-only property that returns True or False.
143 | Works by calculating if the last lock (self.locked_at) has timed out
144 | or not.
145 | """
146 | if not isinstance(self.locked_at, datetime):
147 | return False
148 | return datetime.now() < self.lock_expiration_time
149 |
150 |
151 | @property
152 | def lock_expiration_time(self):
153 | """
154 | The time when the lock will have expired, as a datetime object
155 | """
156 | if not isinstance(self.locked_at, datetime):
157 | return None
158 | return self.locked_at + locking_settings.TIME_UNTIL_EXPIRATION
159 |
160 | @property
161 | def lock_seconds_remaining(self):
162 | """
163 | A read-only property that returns the amount of seconds remaining
164 | before any existing lock times out.
165 |
166 | May or may not return a negative number if the object is currently
167 | unlocked. That number represents the amount of seconds since the last
168 | lock expired.
169 |
170 | If you want to extend a lock beyond its current expiry date, initiate
171 | a new lock using the ``lock_for`` method.
172 | """
173 | if not self.locked_at:
174 | return 0
175 | locked_delta = datetime.now() - self.locked_at
176 | # If the lock has already expired, there are 0 seconds remaining
177 | if locking_settings.TIME_UNTIL_EXPIRATION < locked_delta:
178 | return 0
179 | until = locking_settings.TIME_UNTIL_EXPIRATION - locked_delta
180 | return timedelta_to_seconds(until)
181 |
182 | def lock_for(self, user, hard_lock=True, lock_duration=None, override=False):
183 | """
184 | Together with ``unlock_for`` this is probably the most important
185 | method on this model. If applicable to your use-case, you should lock
186 | for a specific user; that way, we can throw an exception when another
187 | user tries to unlock an object they haven't locked themselves.
188 |
189 | When using soft locks, any process can still use the save method
190 | on this object. If you set ``hard_lock=True``, trying to save an object
191 | without first unlocking will raise an ``ObjectLockedError``.
192 |
193 | Don't use hard locks unless you really need them. See :doc:`design`.
194 |
195 | The 'hard lock' flag is set to True as the default as a fail safe
196 | method to back up javascript lock validations. This is useful when
197 | the user's lock expires or javascript fails to load, etc.
198 | Keep in mind that soft locks are set since they provide the user with
199 | a user friendly locking interface.
200 | """
201 | logger.debug("Attempting to initiate a lock for user `%s`" % user)
202 |
203 | UserModel = get_user_model()
204 |
205 | if not isinstance(user, UserModel):
206 | raise ValueError("You should pass a valid auth.User to lock_for.")
207 |
208 | if self.lock_applies_to(user):
209 | if override:
210 | lock_duration = locking_settings.LOCK_CLEAR_TIMEOUT
211 | else:
212 | raise ObjectLockedError("This object is already locked by another"
213 | " user. May not override, except through the `unlock` method.")
214 | locked_at = datetime.now()
215 | if lock_duration:
216 | locked_at += lock_duration - locking_settings.TIME_UNTIL_EXPIRATION
217 | self._locked_at = locked_at
218 | self._locked_by = user
219 | self._hard_lock = self.__init_hard_lock = hard_lock
220 | # an administrative toggle, to make it easier for devs to extend `django-locking`
221 | # and react to locking and unlocking
222 | self._state.locking = True
223 | logger.debug(
224 | "Initiated a %s lock for `%s` at %s" % (
225 | self.lock_type, self.locked_by, self.locked_at
226 | ))
227 |
228 | def unlock(self):
229 | """
230 | This method serves solely to allow the application itself or admin
231 | users to do manual lock overrides, even if they haven't initiated
232 | these locks themselves. Otherwise, use ``unlock_for``.
233 | """
234 | self._locked_at = self._locked_by = None
235 | # an administrative toggle, to make it easier for devs to extend `django-locking`
236 | # and react to locking and unlocking
237 | self._state.locking = True
238 | logger.debug("Disengaged lock on `%s`" % self)
239 |
240 | def unlock_for(self, user, override=False):
241 | """
242 | See ``lock_for``. If the lock was initiated for a specific user,
243 | unlocking will fail unless that same user requested the unlocking.
244 | Manual overrides should use the ``unlock`` method instead.
245 |
246 | Will raise a ObjectLockedError exception when the current user isn't
247 | authorized to unlock the object.
248 | """
249 | logger.debug("Attempting to open up a lock on `%s` by user `%s`" % (
250 | self, user))
251 | if override:
252 | self.lock_for(user, override=override)
253 | else:
254 | if not self.lock_applies_to(user):
255 | self.unlock()
256 | self.save()
257 |
258 | def lock_applies_to(self, user):
259 | """
260 | A lock does not apply to the user who initiated the lock. Thus,
261 | ``lock_applies_to`` is used to ascertain whether a user is allowed
262 | to edit a locked object.
263 | """
264 | logger.debug("Checking if the lock on `%s` applies to user `%s`" % (
265 | self, user))
266 | # a lock does not apply to the person who initiated the lock
267 | user_pk = getattr(user, 'pk', None)
268 | locked_user_pk = self._locked_by_id
269 | if self.is_locked and locked_user_pk != user_pk:
270 | logger.debug("Lock applies.")
271 | return True
272 | else:
273 | logger.debug("Lock does not apply.")
274 | return False
275 |
276 | def is_locked_by(self, user):
277 | """
278 | Returns True or False. Can be used to test whether this object is
279 | locked by a certain user. The ``lock_applies_to`` method and the
280 | ``is_locked`` and ``locked_by`` attributes are probably more useful
281 | for most intents and
282 | purposes.
283 | """
284 | user_pk = getattr(user, 'pk', None)
285 | locked_user_pk = self._locked_by_id
286 | return bool(self.is_locked and user_pk and locked_user_pk == user_pk)
287 |
288 | def save(self, *vargs, **kwargs):
289 | super(Lock, self).save(*vargs, **kwargs)
290 | self._state.locking = False
291 |
--------------------------------------------------------------------------------
/locking/static/locking/js/admin.locking.js:
--------------------------------------------------------------------------------
1 | /*
2 | Client side handling of locking for the ModelAdmin change page.
3 |
4 | Only works on change-form pages, not for inline edits in the list view.
5 | */
6 |
7 | // Set the namespace.
8 | var DJANGO_LOCKING = DJANGO_LOCKING || {};
9 |
10 | // Make sure jQuery is available.
11 | (function($) {
12 |
13 | if (typeof $.fn.hasClasses === 'undefined') {
14 | var re_classNameWhitespace = /[\n\t\r ]+/g;
15 |
16 | $.fn.hasClasses = function(classes) {
17 | if (!classes || typeof(classes) != 'object' || !classes.length) {
18 | return false;
19 | }
20 | var i,
21 | l = this.length,
22 | classNameRegex = new RegExp("( " + classes.join(" | ") + " )");
23 | for (i = 0; i < l; i++) {
24 | if (this[i].nodeType !== 1) {
25 | continue;
26 | }
27 | var testStr = (" " + this[i].className + " ").replace(re_classNameWhitespace, " ");
28 | if (classNameRegex.test(testStr)) {
29 | return true;
30 | }
31 | }
32 | return false;
33 | };
34 | }
35 |
36 | if (typeof $.fn.bindFirst === 'undefined') {
37 | $.fn.bindFirst = function(name, fn) {
38 | // bind as you normally would
39 | // don't want to miss out on any jQuery magic
40 | this.on(name, fn);
41 |
42 | // Thanks to a comment by @Martin, adding support for
43 | // namespaced events too.
44 | this.each(function() {
45 | var handlers = $._data(this, 'events')[name.split('.')[0]];
46 | // take out the handler we just inserted from the end
47 | var handler = handlers.pop();
48 | // move it at the beginning
49 | handlers.splice(0, 0, handler);
50 | });
51 | };
52 | }
53 |
54 | // We're currently not doing anything here...
55 | DJANGO_LOCKING.error = function() {
56 | return;
57 | };
58 |
59 | var LockManager = function(notificationElement) {
60 | this.$notificationElement = $(notificationElement);
61 | this.config = DJANGO_LOCKING.config || {};
62 | this.urls = this.config.urls || {};
63 |
64 | for (var key in this.text) {
65 | if (typeof gettext == 'function') {
66 | this.text[key] = gettext(this.text[key]);
67 | }
68 | }
69 |
70 | var self = this;
71 | $(document).on('click', 'a.locking-status', function(e) {
72 | return self.removeLockOnClick(e);
73 | });
74 |
75 | // Disable lock when you leave
76 | $(window).on('beforeunload', function() {
77 |
78 | // We have to assure that our lock_clear request actually
79 | // gets through before the user leaves the page, so it
80 | // shouldn't run asynchronously.
81 | if (!self.urls.lock_clear) {
82 | return;
83 | }
84 | if (!self.lockingSupport) {
85 | return;
86 | }
87 |
88 | $.ajax({
89 | url: self.urls.lock_clear,
90 | async: false,
91 | cache: false
92 | });
93 |
94 | });
95 | $(document).on('click', 'a', function(evt) {
96 | return self.onLinkClick(evt);
97 | });
98 | $('a').bindFirst('click', function(evt) {
99 | self.onLinkClick(evt);
100 | });
101 |
102 | this.refreshLock();
103 | };
104 |
105 | $.extend(LockManager.prototype, {
106 | isDisabled: false,
107 | onLinkClick: function(e) {
108 | var self = this;
109 | $a = $(e.target);
110 | if (!self.isDisabled) {
111 | return true;
112 | }
113 |
114 | var isHandler = $a.hasClasses([
115 | 'grp-add-handler', 'add-handler',
116 | 'add-another',
117 | 'grp-delete-handler', 'delete-handler',
118 | 'delete-link',
119 | 'remove-handler', 'grp-remove-handler',
120 | 'arrow-up-handler', 'grp-arrow-up-handler',
121 | 'arrow-down-handler', 'grp-arrow-down-handler'
122 | ]);
123 | if (isHandler) {
124 | e.stopPropagation();
125 | e.preventDefault();
126 | alert("Page is locked");
127 | e.returnValue = false;
128 | return false;
129 | }
130 | },
131 | toggleCKEditorReadonly: function(isReadOnly) {
132 | var toggleEditor = function(editor) {
133 | if (editor.status == 'ready' || editor.status == 'basic_ready') {
134 | editor.setReadOnly(isReadOnly);
135 | } else {
136 | editor.on('contentDom', function(e) {
137 | e.editor.setReadOnly(isReadOnly);
138 | });
139 | }
140 | };
141 | if (window.CKEDITOR !== undefined) {
142 | switch (CKEDITOR.status) {
143 | case 'basic_ready':
144 | case 'ready':
145 | case 'loaded':
146 | case 'basic_loaded':
147 | for (var instanceId in CKEDITOR.instances) {
148 | toggleEditor(CKEDITOR.instances[instanceId]);
149 | }
150 | break;
151 | default:
152 | CKEDITOR.on("instanceReady", function(e) {
153 | toggleEditor(e.editor);
154 | });
155 | break;
156 | }
157 | }
158 | },
159 | enableForm: function() {
160 | if (!this.isDisabled) {
161 | return;
162 | }
163 | this.isDisabled = false;
164 | $(":input:not(.django-select2, .django-ckeditor-textarea)").not('._locking_initially_disabled').removeAttr("disabled");
165 | $("body").removeClass("is-locked");
166 |
167 | this.toggleCKEditorReadonly(false);
168 |
169 | if (typeof $.fn.select2 === "function") {
170 | $('.django-select2').select2("enable", true);
171 | }
172 | $(document).trigger('locking:enabled');
173 | },
174 | disableForm: function(data) {
175 | if (this.isDisabled) {
176 | return;
177 | }
178 | this.isDisabled = true;
179 | this.lockingSupport = false;
180 | data = data || {};
181 | if (this.lockOwner && this.lockOwner == (this.currentUser || data.current_user)) {
182 | var msg;
183 | if (data.locked_by) {
184 | msg = data.locked_by + " removed your lock.";
185 | this.updateNotification(this.text.lock_removed, data);
186 | } else {
187 | msg = "You lost your lock.";
188 | this.updateNotification(this.text.has_expired, data);
189 | }
190 | alert(msg);
191 | } else {
192 | this.updateNotification(this.text.is_locked, data);
193 | }
194 | $(":input[disabled]").addClass('_locking_initially_disabled');
195 | $(":input:not(.django-select2, .django-ckeditor-textarea)").attr("disabled", "disabled");
196 | $("body").addClass("is-locked");
197 |
198 | this.toggleCKEditorReadonly(true);
199 |
200 | if (typeof $.fn.select2 === "function") {
201 | $('.django-select2').select2("enable", false);
202 | }
203 | $(document).trigger('locking:disabled');
204 | },
205 | text: {
206 | warn: 'Your lock on this page expires in less than %s ' +
207 | 'minutes. Press save or reload the page.',
208 | lock_removed: 'User "%(locked_by_name)s" removed your lock. If you save, ' +
209 | 'your attempts may be thwarted due to another lock ' +
210 | ' or you may have stale data.',
211 | is_locked: 'This page is locked by %(locked_by_name)s ' +
212 | 'and editing is disabled.',
213 | has_expired: 'You have lost your lock on this page. If you save, ' +
214 | 'your attempts may be thwarted due to another lock ' +
215 | ' or you may have stale data.',
216 | prompt_save: 'Do you wish to save the page?'
217 | },
218 | lockOwner: null,
219 | currentUser: null,
220 | refreshTimeout: null,
221 | lockingSupport: true, // false for changelist views and new objects
222 | refreshLock: function() {
223 | if (!this.urls.lock) {
224 | return;
225 | }
226 | var self = this;
227 |
228 | $.ajax({
229 | url: self.urls.lock,
230 | cache: false,
231 | success: function(data, textStatus, jqXHR) {
232 | // The server gave us locking info. Either lock or keep it
233 | // unlocked while showing notification.
234 | if (!self.currentUser) {
235 | self.currentUser = data.current_user;
236 | }
237 | if (!data.applies) {
238 | self.enableForm();
239 | } else {
240 | self.disableForm(data);
241 | }
242 | self.lockOwner = data.locked_by;
243 | },
244 | error: function(jqXHR, textStatus, errorThrown) {
245 | try {
246 | data = $.parseJSON(jqXHR.responseText) || {};
247 | } catch(e) {
248 | data = {};
249 | }
250 | if (!self.currentUser) {
251 | self.currentUser = data.current_user;
252 | }
253 | if (jqXHR.status === 404) {
254 | self.lockingSupport = false;
255 | self.enableForm();
256 | return;
257 | } else if (jqXHR.status === 423) {
258 | self.disableForm(data);
259 | } else {
260 | DJANGO_LOCKING.error();
261 | }
262 | self.lockOwner = data.locked_by;
263 | },
264 | complete: function() {
265 | if (self.refreshTimeout) {
266 | clearTimeout(self.refreshTimeout);
267 | self.refreshTimeout = null;
268 | }
269 | if (!self.lockingSupport) {
270 | return;
271 | }
272 | self.refreshTimeout = setTimeout(function() { self.refreshLock(); }, 30000);
273 | }
274 | });
275 | },
276 | getUrl: function(action, id) {
277 | var baseUrl = this.urls[action];
278 | if (typeof baseUrl == 'undefined') {
279 | return null;
280 | }
281 | var regex = new RegExp("\/0\/" + action + "\/$");
282 | return baseUrl.replace(regex, "/" + id + "/" + action + "/");
283 | },
284 | updateNotification: function(text, data) {
285 | $('html, body').scrollTop(0);
286 | text = interpolate(text, data, true);
287 | this.$notificationElement.html(text).hide().fadeIn('slow');
288 | },
289 | // Locking toggle function
290 | removeLockOnClick: function(e) {
291 | e.preventDefault();
292 | var $link = $(e.target);
293 | if (!$link.hasClass('locking-locked')) {
294 | return;
295 | }
296 | var user = $link.attr('data-locked-by');
297 | var lockedObjId = $link.attr('data-locked-obj-id');
298 | var removeLockUrl = this.getUrl("lock_remove", lockedObjId);
299 | if (removeLockUrl) {
300 | if (confirm("User '" + user + "' is currently editing this " +
301 | "content. Proceed with lock removal?")) {
302 | $.ajax({
303 | url: removeLockUrl,
304 | async: false,
305 | success: function() {
306 | $link.hide();
307 | }
308 | });
309 | }
310 | }
311 | }
312 | });
313 | $.fn.djangoLocking = function() {
314 | // Only use the first element in the jQuery list
315 | var $this = this.eq(0);
316 | var lockManager = $this.data('djangoLocking');
317 | if (!lockManager) {
318 | lockManager = new LockManager($this);
319 | }
320 | return lockManager;
321 | };
322 |
323 | $(document).ready(function() {
324 | var $target = $("#content-inner, #content").eq(0);
325 | var $notificationElement = $('').prependTo($target);
326 | $notificationElement.djangoLocking();
327 | });
328 |
329 | })((typeof grp == 'object' && grp.jQuery)
330 | ? grp.jQuery
331 | : (typeof django == 'object' && django.jQuery) ? django.jQuery : jQuery);
332 |
--------------------------------------------------------------------------------
/locking/tests/tests.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta
2 | import json
3 |
4 | from django.core.urlresolvers import reverse
5 | from django.test.client import Client
6 | from django.contrib.auth.models import User
7 |
8 | from locking import models, views, settings as locking_settings
9 | from locking.tests.utils import TestCase
10 | from locking.tests import models as testmodels
11 |
12 |
13 | json_decode = json.JSONDecoder().decode
14 |
15 |
16 | class AppTestCase(TestCase):
17 |
18 | fixtures = ['locking_scenario',]
19 |
20 | def setUp(self):
21 | self.alt_story, self.story = testmodels.Story.objects.all()
22 | users = User.objects.all()
23 | self.user, self.alt_user = users
24 |
25 | def test_hard_lock(self):
26 | # you can save a hard lock once (to initiate the lock)
27 | # but after that saving without first unlocking raises an error
28 | self.story.lock_for(self.user, hard_lock=True)
29 | self.assertEquals(self.story.lock_type, "hard")
30 | self.story.save()
31 | self.assertRaises(models.ObjectLockedError, self.story.save)
32 |
33 | def test_soft_lock(self):
34 | self.story.lock_for(self.user)
35 | self.story.save()
36 | self.assertEquals(self.story.lock_type, "soft")
37 | self.story.save()
38 |
39 | def test_lock_for(self):
40 | self.story.lock_for(self.user)
41 | self.assertTrue(self.story.is_locked)
42 | self.story.save()
43 | self.assertTrue(self.story.is_locked)
44 |
45 | def test_lock_for_overwrite(self):
46 | # we shouldn't be able to overwrite an active lock by another user
47 | self.story.lock_for(self.alt_user)
48 | self.assertRaises(models.ObjectLockedError, self.story.lock_for, self.user)
49 |
50 | def test_unlock(self):
51 | self.story.lock_for(self.user)
52 | self.story.unlock()
53 | self.assertFalse(self.story.is_locked)
54 |
55 | def test_hard_unlock(self):
56 | self.story.lock_for(self.user, hard_lock=True)
57 | self.story.unlock_for(self.user)
58 | self.assertFalse(self.story.is_locked)
59 | self.story.unlock()
60 |
61 | def test_unlock_for_self(self):
62 | self.story.lock_for(self.user)
63 | self.story.unlock_for(self.user)
64 | self.assertFalse(self.story.is_locked)
65 |
66 | def test_unlock_for_disallowed(self, hard_lock=False):
67 | # we shouldn't be able to disengage a lock that was put in place by another user
68 | self.story.lock_for(self.alt_user, hard_lock=hard_lock)
69 | self.assertRaises(models.ObjectLockedError, self.story.unlock_for, self.user)
70 |
71 | def test_hard_unlock_for_disallowed(self):
72 | self.test_unlock_for_disallowed(hard_lock=True)
73 |
74 | def test_lock_expiration(self):
75 | self.story.lock_for(self.user)
76 | self.assertTrue(self.story.is_locked)
77 | self.story._locked_at = datetime.today() - timedelta(minutes=locking_settings.LOCK_TIMEOUT+1)
78 | self.assertFalse(self.story.is_locked)
79 |
80 | def test_lock_expiration_day(self):
81 | self.story.lock_for(self.user)
82 | self.assertTrue(self.story.is_locked)
83 | self.story._locked_at = datetime.today() - timedelta(days=1, seconds=1)
84 | self.assertFalse(self.story.is_locked)
85 |
86 | def test_lock_seconds_remaining(self):
87 | self.story.lock_for(self.user)
88 | expected = locking_settings.LOCK_TIMEOUT
89 | self.assertTrue(self.story.lock_seconds_remaining <= expected + 1 and
90 | self.story.lock_seconds_remaining >= expected - 1,
91 | "%d not close to %d" % (
92 | self.story.lock_seconds_remaining, expected))
93 |
94 | def test_lock_seconds_remaining_half_timeout(self):
95 | self.story.lock_for(self.user)
96 | self.story._locked_at -= timedelta(seconds=(locking_settings.LOCK_TIMEOUT / 2))
97 | expected = locking_settings.LOCK_TIMEOUT / 2
98 | self.assertTrue(self.story.lock_seconds_remaining <= expected + 1 and
99 | self.story.lock_seconds_remaining >= expected - 1,
100 | "%d not close to %d" % (
101 | self.story.lock_seconds_remaining, expected))
102 |
103 | def test_lock_seconds_remaining_day(self):
104 | self.story.lock_for(self.user)
105 | self.story._locked_at -= timedelta(days=1)
106 | expected = locking_settings.LOCK_TIMEOUT - (24 * 60 * 60)
107 | self.assertTrue(self.story.lock_seconds_remaining <= expected + 1 and
108 | self.story.lock_seconds_remaining >= expected - 1,
109 | "%d not close to %d" % (
110 | self.story.lock_seconds_remaining, expected))
111 |
112 | def test_lock_applies_to(self):
113 | self.story.lock_for(self.alt_user)
114 | applies = self.story.lock_applies_to(self.user)
115 | self.assertTrue(applies)
116 |
117 | def test_lock_doesnt_apply_to(self):
118 | self.story.lock_for(self.user)
119 | applies = self.story.lock_applies_to(self.user)
120 | self.assertFalse(applies)
121 |
122 | def test_is_locked_by(self):
123 | self.story.lock_for(self.user)
124 | self.assertEquals(self.story.locked_by, self.user)
125 |
126 | def test_is_unlocked(self):
127 | # this might seem like a silly test, but an object
128 | # should be unlocked unless it has actually been locked
129 | self.assertFalse(self.story.is_locked)
130 |
131 | def test_locking_bit_when_locking(self):
132 | # when we've locked something, we should set an administrative
133 | # bit so other developers can know a save will do a lock or
134 | # unlock and respond to that information if they so wish.
135 | self.story.content = "Blah"
136 | self.assertEquals(self.story._state.locking, False)
137 | self.story.lock_for(self.user)
138 | self.assertEquals(self.story._state.locking, True)
139 | self.story.save()
140 | self.assertEquals(self.story._state.locking, False)
141 |
142 | def test_locking_bit_when_unlocking(self):
143 | # when we've locked something, we should set an administrative
144 | # bit so other developers can know a save will do a lock or
145 | # unlock and respond to that information if they so wish.
146 | self.story.content = "Blah"
147 | self.assertEquals(self.story._state.locking, False)
148 | self.story.lock_for(self.user)
149 | self.story.unlock_for(self.user)
150 | self.assertEquals(self.story._state.locking, True)
151 | self.story.save()
152 | self.assertEquals(self.story._state.locking, False)
153 |
154 | def test_unlocked_manager(self):
155 | self.story.lock_for(self.user)
156 | self.story.save()
157 | self.assertEquals(testmodels.Story.objects.count(), 2)
158 | self.assertEquals(testmodels.Story.unlocked.count(), 1)
159 | self.assertEquals(testmodels.Story.unlocked.get(pk=self.alt_story.pk).pk, 1)
160 | self.assertRaises(testmodels.Story.DoesNotExist, testmodels.Story.unlocked.get, pk=self.story.pk)
161 | self.assertNotEquals(testmodels.Story.unlocked.all()[0].pk, self.story.pk)
162 |
163 | def test_locked_manager(self):
164 | self.story.lock_for(self.user)
165 | self.story.save()
166 | self.assertEquals(testmodels.Story.objects.count(), 2)
167 | self.assertEquals(testmodels.Story.locked.count(), 1)
168 | self.assertEquals(testmodels.Story.locked.get(pk=self.story.pk).pk, 2)
169 | self.assertRaises(testmodels.Story.DoesNotExist, testmodels.Story.locked.get, pk=self.alt_story.pk)
170 | self.assertEquals(testmodels.Story.locked.all()[0].pk, self.story.pk)
171 |
172 | def test_managers(self):
173 | self.story.lock_for(self.user)
174 | self.story.save()
175 | locked = testmodels.Story.locked.all()
176 | unlocked = testmodels.Story.unlocked.all()
177 | self.assertEquals(locked.count(), 1)
178 | self.assertEquals(unlocked.count(), 1)
179 | self.assertTrue(len(set(locked).intersection(set(unlocked))) == 0)
180 |
181 |
182 | users = [
183 | # Stan is a superuser
184 | {
185 | "username": "Stan",
186 | "password": "green pastures"
187 | },
188 | # Fred has pretty much no permissions whatsoever
189 | {
190 | "username": "Fred",
191 | "password": "pastures of green"
192 | },
193 | ]
194 |
195 |
196 | class BrowserTestCase(TestCase):
197 |
198 | fixtures = ['locking_scenario',]
199 | apps = ('locking.tests', 'django.contrib.auth', 'django.contrib.admin', )
200 | # REFACTOR:
201 | # urls = 'locking.tests.urls'
202 |
203 | def setUp(self):
204 | # some objects we might use directly, instead of via the client
205 | self.story = story = testmodels.Story.objects.all()[0]
206 | user_objs = User.objects.all()
207 | self.user, self.alt_user = user_objs
208 | # client setup
209 | self.c = Client()
210 | self.c.login(**users[0])
211 | # refactor: http://docs.djangoproject.com/en/dev/topics/testing/#urlconf-configuration
212 | # is probably a smarter way to go about this
213 | info = ('tests', 'story')
214 | self.urls = {
215 | "change": reverse('admin:%s_%s_change' % info, args=[story.pk]),
216 | "changelist": reverse('admin:%s_%s_changelist' % info),
217 | "lock": reverse('admin:%s_%s_lock' % info, args=[story.pk]),
218 | "unlock": reverse('admin:%s_%s_unlock' % info, args=[story.pk]),
219 | "is_locked": reverse('admin:%s_%s_lock_status' % info, args=[story.pk]),
220 | "is_locked": reverse('admin:%s_%s_lock_js' % info, args=[story.pk]),
221 | }
222 |
223 | def tearDown(self):
224 | pass
225 |
226 | # Some terminology:
227 | # - 'disallowed' is when the locking system does not allow a certain operation
228 | # - 'unauthorized' is when Django does not permit a user to do something
229 | # - 'unauthenticated' is when a user is logged out of Django
230 |
231 | def test_lock_when_allowed(self):
232 | res = self.c.get(self.urls['lock'])
233 | self.assertEquals(res.status_code, 200)
234 | # reload our test story
235 | story = testmodels.Story.objects.get(pk=self.story.id)
236 | self.assertTrue(story.is_locked)
237 |
238 | def test_lock_when_logged_out(self):
239 | self.c.logout()
240 | res = self.c.get(self.urls['lock'])
241 | self.assertEquals(res.status_code, 401)
242 |
243 | def test_lock_when_unauthorized(self):
244 | # when a user doesn't have permission to change the model
245 | # this tests the user_may_change_model decorator
246 | self.c.logout()
247 | self.c.login(**users[1])
248 | res = self.c.get(self.urls['lock'])
249 | self.assertEquals(res.status_code, 401)
250 |
251 | def test_lock_when_does_not_apply(self):
252 | # don't make a resource available to lock models that don't
253 | # have locking enabled -- this tests the is_lockable decorator
254 | obj = testmodels.Unlockable.objects.get(pk=1)
255 | args = [obj._meta.app_label, obj._meta.module_name, obj.pk]
256 | url = reverse(views.lock, args=args)
257 | res = self.c.get(url)
258 | self.assertEquals(res.status_code, 404)
259 |
260 | def test_lock_when_disallowed(self):
261 | self.story.lock_for(self.alt_user)
262 | self.story.save()
263 | res = self.c.get(self.urls['lock'])
264 | self.assertEquals(res.status_code, 403)
265 |
266 | def test_unlock_when_allowed(self):
267 | self.story.lock_for(self.user)
268 | self.story.save()
269 | res = self.c.get(self.urls['unlock'])
270 | self.assertEquals(res.status_code, 200)
271 | # reload our test story
272 | story = testmodels.Story.objects.get(pk=self.story.id)
273 | self.assertFalse(story.is_locked)
274 |
275 | def test_unlock_when_disallowed(self):
276 | self.story.lock_for(self.alt_user)
277 | self.story.save()
278 | res = self.c.get(self.urls['unlock'])
279 | self.assertEquals(res.status_code, 403)
280 |
281 | def test_is_locked_when_applies(self):
282 | self.story.lock_for(self.alt_user)
283 | self.story.save()
284 | res = self.c.get(self.urls['is_locked'])
285 | res = json_decode(res.content)
286 | self.assertTrue(res['applies'])
287 | self.assertTrue(res['is_active'])
288 |
289 | def test_is_locked_when_self(self):
290 | self.story.lock_for(self.user)
291 | self.story.save()
292 | res = self.c.get(self.urls['is_locked'])
293 | res = json_decode(res.content)
294 | self.assertFalse(res['applies'])
295 | self.assertTrue(res['is_active'])
296 |
297 | def test_js_variables(self):
298 | res = self.c.get(self.urls['js_variables'])
299 | self.assertEquals(res.status_code, 200)
300 | self.assertContains(res, locking_settings.LOCK_TIMEOUT)
301 |
302 | def test_admin_media(self):
303 | res = self.c.get(self.urls['change'])
304 | self.assertContains(res, 'admin.locking.js')
305 |
306 | def test_admin_changelist_when_locked(self):
307 | self.story.lock_for(self.alt_user)
308 | self.story.save()
309 | res = self.c.get(self.urls['changelist'])
310 | self.assertContains(res, 'locking/img/lock.png')
311 |
312 | def test_admin_changelist_when_locked_self(self):
313 | self.test_lock_when_allowed()
314 | res = self.c.get(self.urls['changelist'])
315 | self.assertContains(res, 'locking/img/page_edit.png')
316 |
317 | def test_admin_changelist_when_unlocked(self):
318 | res = self.c.get(self.urls['changelist'])
319 | self.assertNotContains(res, 'locking/img')
320 |
--------------------------------------------------------------------------------