diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48cc71c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.pyc +docs/_build +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bf78def --- /dev/null +++ b/LICENSE @@ -0,0 +1,25 @@ +Copyright 2010 Stijn Debrouwere. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are +permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, this list of + conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, this list + of conditions and the following disclaimer in the documentation and/or other materials + provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY STIJN DEBROUWERE ``AS IS'' AND ANY EXPRESS OR IMPLIED +WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL STIJN DEBROUWERE OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +The views and conclusions contained in the software and documentation are those of the +authors and should not be interpreted as representing official policies, either expressed +or implied, of Stijn Debrouwere. \ No newline at end of file diff --git a/README b/README new file mode 100644 index 0000000..2b8c376 --- /dev/null +++ b/README @@ -0,0 +1,13 @@ +## What the heck is this? + +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. + +`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. + +## Documentation + +`django-locking` is well-documented. Check out the docs! + +## License + +django-locking comes with the simplified BSD license, see the included LICENSE file for details. \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..b8d287f --- /dev/null +++ b/__init__.py @@ -0,0 +1,8 @@ +import sys +import logging +from django.conf import settings + +LOCK_TIMEOUT = getattr(settings, 'LOCK_TIMEOUT', 1800) +LOCKING_LOG_LEVEL = getattr(settings, 'LOCKING_LOG_LEVEL', logging.INFO) + +logging.basicConfig(stream=sys.stderr, level=LOCKING_LOG_LEVEL) \ No newline at end of file diff --git a/admin.py b/admin.py new file mode 100644 index 0000000..ebda5b8 --- /dev/null +++ b/admin.py @@ -0,0 +1,63 @@ +# encoding: utf-8 + +from datetime import datetime + +from django.contrib import admin +from django.conf import settings +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext as _ +from django import forms + +from locking import LOCK_TIMEOUT, views + +class LockableAdmin(admin.ModelAdmin): + @property + def media(self): + # because reverse() doesn't yet work when this module is first loaded + # (the urlconf still has to load at that point) the media definition + # has to be dynamic, and we can't simply add a Media class to the + # ModelAdmin as you usually would. + # + # Doing so would result in an ImproperlyConfigured exception, stating + # "The included urlconf doesn't have any patterns in it." + # + # See http://docs.djangoproject.com/en/dev/topics/forms/media/#media-as-a-dynamic-property + # for more information about dynamic media definitions. + + css = { + 'all': ('locking/css/locking.css',) + } + js = ( + 'http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js', + 'locking/js/jquery.url.packed.js', + #reverse('django.views.i18n.javascript_catalog'), + reverse('locking_variables'), + 'locking/js/admin.locking.js', + ) + + return forms.Media(css=css, js=js) + + # niet vergeten js en dit hier te documenteren, + # gezien overrides in subklassen zonder supers het anders kapot zouden kunnen maken + def changelist_view(self, request, extra_context=None): + # we need the request objects in a few places where it's usually not present, + # so we're tacking it on to the LockableAdmin class + self.request = request + return super(LockableAdmin, self).changelist_view(request, extra_context) + + def lock(self, obj): + print obj.is_locked + if obj.is_locked: + seconds_remaining = obj.lock_seconds_remaining + minutes_remaining = seconds_remaining/60 + locked_until = _("Still locked for %s minutes by %s") % (minutes_remaining, obj.locked_by) + + if self.request.user == obj.locked_by: + return '' % (settings.MEDIA_URL, locked_until) + else: + return '' % (settings.MEDIA_URL, locked_until) + else: + return '' + lock.allow_tags = True + + list_display = ('lock', ) \ No newline at end of file diff --git a/decorators.py b/decorators.py new file mode 100644 index 0000000..24a4de7 --- /dev/null +++ b/decorators.py @@ -0,0 +1,40 @@ +# encoding: utf-8 + +from django.http import HttpResponse +from django.contrib.contenttypes.models import ContentType + +from locking.models import LockableModel +from locking import logging + +def user_may_change_model(fn): + def view(request, app, model, *vargs, **kwargs): + may_change = '%s.change_%s' % (app, model) + if not request.user.has_perm(may_change): + return HttpResponse(status=401) + else: + return fn(request, app, model, *vargs, **kwargs) + + return view + +def is_lockable(fn): + def view(request, app, model, *vargs, **kwargs): + try: + cls = ContentType.objects.get(app_label=app, model=model).model_class() + if issubclass(cls, LockableModel): + lockable = True + except ContentType.DoesNotExist: + lockable = False + + if lockable: + return fn(request, app, model, *vargs, **kwargs) + else: + return HttpResponse(status=404) + return view + +def log(view): + def decorated_view(*vargs, **kwargs): + response = view(*vargs, **kwargs) + logging.debug("Sending a request: \n\t%s" % (response.content)) + return response + + return decorated_view \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..6ea267d --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,89 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + -rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-locking.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-locking.qhc" + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ + "run these through (pdf)latex." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." diff --git a/docs/_themes/nature/static/nature.css_t b/docs/_themes/nature/static/nature.css_t new file mode 100644 index 0000000..0541c43 --- /dev/null +++ b/docs/_themes/nature/static/nature.css_t @@ -0,0 +1,229 @@ +/** + * Sphinx stylesheet -- default theme + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +@import url("basic.css"); + +/* -- page layout ----------------------------------------------------------- */ + +body { + font-family: Arial, sans-serif; + font-size: 120%; + background-color: #111; + color: #555; + margin: 0; + padding: 0; +} + +div.documentwrapper { + float: left; + width: 100%; +} + +div.bodywrapper { + margin: 0 0 0 230px; +} + +hr{ + border: 1px solid #B1B4B6; +} + +div.document { + background-color: #eee; +} + +div.body { + background-color: #ffffff; + color: #3E4349; + padding: 0 30px 30px 30px; + font-size: 0.8em; +} + +div.footer { + color: #555; + width: 100%; + padding: 13px 0; + text-align: center; + font-size: 75%; +} + +div.footer a { + color: #444; + text-decoration: underline; +} + +div.related { + background-color: #6BA81E; + line-height: 32px; + color: #fff; + text-shadow: 0px 1px 0 #444; + font-size: 0.80em; +} + +div.related a { + color: #E2F3CC; +} + +div.sphinxsidebar { + font-size: 0.75em; + line-height: 1.5em; +} + +div.sphinxsidebarwrapper{ + padding: 20px 0; +} + +div.sphinxsidebar h3, +div.sphinxsidebar h4 { + font-family: Arial, sans-serif; + color: #222; + font-size: 1.2em; + font-weight: normal; + margin: 0; + padding: 5px 10px; + background-color: #ddd; + text-shadow: 1px 1px 0 white +} + +div.sphinxsidebar h4{ + font-size: 1.1em; +} + +div.sphinxsidebar h3 a { + color: #444; +} + + +div.sphinxsidebar p { + color: #888; + padding: 5px 20px; +} + +div.sphinxsidebar p.topless { +} + +div.sphinxsidebar ul { + margin: 10px 20px; + padding: 0; + color: #000; +} + +div.sphinxsidebar a { + color: #444; +} + +div.sphinxsidebar input { + border: 1px solid #ccc; + font-family: sans-serif; + font-size: 1em; +} + +div.sphinxsidebar input[type=text]{ + margin-left: 20px; +} + +/* -- body styles ----------------------------------------------------------- */ + +a { + color: #005B81; + text-decoration: none; +} + +a:hover { + color: #E32E00; + text-decoration: underline; +} + +div.body h1, +div.body h2, +div.body h3, +div.body h4, +div.body h5, +div.body h6 { + font-family: Arial, sans-serif; + background-color: #BED4EB; + font-weight: normal; + color: #212224; + margin: 30px 0px 10px 0px; + padding: 5px 0 5px 10px; + text-shadow: 0px 1px 0 white +} + +div.body h1 { border-top: 20px solid white; margin-top: 0; font-size: 200%; } +div.body h2 { font-size: 150%; background-color: #C8D5E3; } +div.body h3 { font-size: 120%; background-color: #D8DEE3; } +div.body h4 { font-size: 110%; background-color: #D8DEE3; } +div.body h5 { font-size: 100%; background-color: #D8DEE3; } +div.body h6 { font-size: 100%; background-color: #D8DEE3; } + +a.headerlink { + color: #c60f0f; + font-size: 0.8em; + padding: 0 4px 0 4px; + text-decoration: none; +} + +a.headerlink:hover { + background-color: #c60f0f; + color: white; +} + +div.body p, div.body dd, div.body li { + line-height: 1.5em; +} + +div.admonition p.admonition-title + p { + display: inline; +} + +div.highlight{ + background-color: white; +} + +div.note { + background-color: #eee; + border: 1px solid #ccc; +} + +div.seealso { + background-color: #ffc; + border: 1px solid #ff6; +} + +div.topic { + background-color: #eee; +} + +div.warning { + background-color: #ffe4e4; + border: 1px solid #f66; +} + +p.admonition-title { + display: inline; +} + +p.admonition-title:after { + content: ":"; +} + +pre { + padding: 10px; + background-color: White; + color: #222; + line-height: 1.2em; + border: 1px solid #C6C9CB; + font-size: 1.2em; + margin: 1.5em 0 1.5em 0; + -webkit-box-shadow: 1px 1px 1px #d8d8d8; + -moz-box-shadow: 1px 1px 1px #d8d8d8; +} + +tt { + background-color: #ecf0f3; + color: #222; + padding: 1px 2px; + font-size: 1.2em; + font-family: monospace; +} diff --git a/docs/_themes/nature/static/pygments.css b/docs/_themes/nature/static/pygments.css new file mode 100644 index 0000000..652b761 --- /dev/null +++ b/docs/_themes/nature/static/pygments.css @@ -0,0 +1,54 @@ +.c { color: #999988; font-style: italic } /* Comment */ +.k { font-weight: bold } /* Keyword */ +.o { font-weight: bold } /* Operator */ +.cm { color: #999988; font-style: italic } /* Comment.Multiline */ +.cp { color: #999999; font-weight: bold } /* Comment.preproc */ +.c1 { color: #999988; font-style: italic } /* Comment.Single */ +.gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */ +.ge { font-style: italic } /* Generic.Emph */ +.gr { color: #aa0000 } /* Generic.Error */ +.gh { color: #999999 } /* Generic.Heading */ +.gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */ +.go { color: #111 } /* Generic.Output */ +.gp { color: #555555 } /* Generic.Prompt */ +.gs { font-weight: bold } /* Generic.Strong */ +.gu { color: #aaaaaa } /* Generic.Subheading */ +.gt { color: #aa0000 } /* Generic.Traceback */ +.kc { font-weight: bold } /* Keyword.Constant */ +.kd { font-weight: bold } /* Keyword.Declaration */ +.kp { font-weight: bold } /* Keyword.Pseudo */ +.kr { font-weight: bold } /* Keyword.Reserved */ +.kt { color: #445588; font-weight: bold } /* Keyword.Type */ +.m { color: #009999 } /* Literal.Number */ +.s { color: #bb8844 } /* Literal.String */ +.na { color: #008080 } /* Name.Attribute */ +.nb { color: #999999 } /* Name.Builtin */ +.nc { color: #445588; font-weight: bold } /* Name.Class */ +.no { color: #ff99ff } /* Name.Constant */ +.ni { color: #800080 } /* Name.Entity */ +.ne { color: #990000; font-weight: bold } /* Name.Exception */ +.nf { color: #990000; font-weight: bold } /* Name.Function */ +.nn { color: #555555 } /* Name.Namespace */ +.nt { color: #000080 } /* Name.Tag */ +.nv { color: purple } /* Name.Variable */ +.ow { font-weight: bold } /* Operator.Word */ +.mf { color: #009999 } /* Literal.Number.Float */ +.mh { color: #009999 } /* Literal.Number.Hex */ +.mi { color: #009999 } /* Literal.Number.Integer */ +.mo { color: #009999 } /* Literal.Number.Oct */ +.sb { color: #bb8844 } /* Literal.String.Backtick */ +.sc { color: #bb8844 } /* Literal.String.Char */ +.sd { color: #bb8844 } /* Literal.String.Doc */ +.s2 { color: #bb8844 } /* Literal.String.Double */ +.se { color: #bb8844 } /* Literal.String.Escape */ +.sh { color: #bb8844 } /* Literal.String.Heredoc */ +.si { color: #bb8844 } /* Literal.String.Interpol */ +.sx { color: #bb8844 } /* Literal.String.Other */ +.sr { color: #808000 } /* Literal.String.Regex */ +.s1 { color: #bb8844 } /* Literal.String.Single */ +.ss { color: #bb8844 } /* Literal.String.Symbol */ +.bp { color: #999999 } /* Name.Builtin.Pseudo */ +.vc { color: #ff99ff } /* Name.Variable.Class */ +.vg { color: #ff99ff } /* Name.Variable.Global */ +.vi { color: #ff99ff } /* Name.Variable.Instance */ +.il { color: #009999 } /* Literal.Number.Integer.Long */ \ No newline at end of file diff --git a/docs/_themes/nature/theme.conf b/docs/_themes/nature/theme.conf new file mode 100644 index 0000000..1cc4004 --- /dev/null +++ b/docs/_themes/nature/theme.conf @@ -0,0 +1,4 @@ +[theme] +inherit = basic +stylesheet = nature.css +pygments_style = tango diff --git a/docs/_themes/static/nature.css_t b/docs/_themes/static/nature.css_t new file mode 100644 index 0000000..03b0379 --- /dev/null +++ b/docs/_themes/static/nature.css_t @@ -0,0 +1,229 @@ +/** + * Sphinx stylesheet -- default theme + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + */ + +@import url("basic.css"); + +/* -- page layout ----------------------------------------------------------- */ + +body { + font-family: Arial, sans-serif; + font-size: 100%; + background-color: #111; + color: #555; + margin: 0; + padding: 0; +} + +div.documentwrapper { + float: left; + width: 100%; +} + +div.bodywrapper { + margin: 0 0 0 230px; +} + +hr{ + border: 1px solid #B1B4B6; +} + +div.document { + background-color: #eee; +} + +div.body { + background-color: #ffffff; + color: #3E4349; + padding: 0 30px 30px 30px; + font-size: 0.8em; +} + +div.footer { + color: #555; + width: 100%; + padding: 13px 0; + text-align: center; + font-size: 75%; +} + +div.footer a { + color: #444; + text-decoration: underline; +} + +div.related { + background-color: #6BA81E; + line-height: 32px; + color: #fff; + text-shadow: 0px 1px 0 #444; + font-size: 0.80em; +} + +div.related a { + color: #E2F3CC; +} + +div.sphinxsidebar { + font-size: 0.75em; + line-height: 1.5em; +} + +div.sphinxsidebarwrapper{ + padding: 20px 0; +} + +div.sphinxsidebar h3, +div.sphinxsidebar h4 { + font-family: Arial, sans-serif; + color: #222; + font-size: 1.2em; + font-weight: normal; + margin: 0; + padding: 5px 10px; + background-color: #ddd; + text-shadow: 1px 1px 0 white +} + +div.sphinxsidebar h4{ + font-size: 1.1em; +} + +div.sphinxsidebar h3 a { + color: #444; +} + + +div.sphinxsidebar p { + color: #888; + padding: 5px 20px; +} + +div.sphinxsidebar p.topless { +} + +div.sphinxsidebar ul { + margin: 10px 20px; + padding: 0; + color: #000; +} + +div.sphinxsidebar a { + color: #444; +} + +div.sphinxsidebar input { + border: 1px solid #ccc; + font-family: sans-serif; + font-size: 1em; +} + +div.sphinxsidebar input[type=text]{ + margin-left: 20px; +} + +/* -- body styles ----------------------------------------------------------- */ + +a { + color: #005B81; + text-decoration: none; +} + +a:hover { + color: #E32E00; + text-decoration: underline; +} + +div.body h1, +div.body h2, +div.body h3, +div.body h4, +div.body h5, +div.body h6 { + font-family: Arial, sans-serif; + background-color: #BED4EB; + font-weight: normal; + color: #212224; + margin: 30px 0px 10px 0px; + padding: 5px 0 5px 10px; + text-shadow: 0px 1px 0 white +} + +div.body h1 { border-top: 20px solid white; margin-top: 0; font-size: 200%; } +div.body h2 { font-size: 150%; background-color: #C8D5E3; } +div.body h3 { font-size: 120%; background-color: #D8DEE3; } +div.body h4 { font-size: 110%; background-color: #D8DEE3; } +div.body h5 { font-size: 100%; background-color: #D8DEE3; } +div.body h6 { font-size: 100%; background-color: #D8DEE3; } + +a.headerlink { + color: #c60f0f; + font-size: 0.8em; + padding: 0 4px 0 4px; + text-decoration: none; +} + +a.headerlink:hover { + background-color: #c60f0f; + color: white; +} + +div.body p, div.body dd, div.body li { + line-height: 1.5em; +} + +div.admonition p.admonition-title + p { + display: inline; +} + +div.highlight{ + background-color: white; +} + +div.note { + background-color: #eee; + border: 1px solid #ccc; +} + +div.seealso { + background-color: #ffc; + border: 1px solid #ff6; +} + +div.topic { + background-color: #eee; +} + +div.warning { + background-color: #ffe4e4; + border: 1px solid #f66; +} + +p.admonition-title { + display: inline; +} + +p.admonition-title:after { + content: ":"; +} + +pre { + padding: 10px; + background-color: White; + color: #222; + line-height: 1.2em; + border: 1px solid #C6C9CB; + font-size: 1.2em; + margin: 1.5em 0 1.5em 0; + -webkit-box-shadow: 1px 1px 1px #d8d8d8; + -moz-box-shadow: 1px 1px 1px #d8d8d8; +} + +tt { + background-color: #ecf0f3; + color: #222; + padding: 1px 2px; + font-size: 1.2em; + font-family: monospace; +} diff --git a/docs/_themes/static/pygments.css b/docs/_themes/static/pygments.css new file mode 100644 index 0000000..652b761 --- /dev/null +++ b/docs/_themes/static/pygments.css @@ -0,0 +1,54 @@ +.c { color: #999988; font-style: italic } /* Comment */ +.k { font-weight: bold } /* Keyword */ +.o { font-weight: bold } /* Operator */ +.cm { color: #999988; font-style: italic } /* Comment.Multiline */ +.cp { color: #999999; font-weight: bold } /* Comment.preproc */ +.c1 { color: #999988; font-style: italic } /* Comment.Single */ +.gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */ +.ge { font-style: italic } /* Generic.Emph */ +.gr { color: #aa0000 } /* Generic.Error */ +.gh { color: #999999 } /* Generic.Heading */ +.gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */ +.go { color: #111 } /* Generic.Output */ +.gp { color: #555555 } /* Generic.Prompt */ +.gs { font-weight: bold } /* Generic.Strong */ +.gu { color: #aaaaaa } /* Generic.Subheading */ +.gt { color: #aa0000 } /* Generic.Traceback */ +.kc { font-weight: bold } /* Keyword.Constant */ +.kd { font-weight: bold } /* Keyword.Declaration */ +.kp { font-weight: bold } /* Keyword.Pseudo */ +.kr { font-weight: bold } /* Keyword.Reserved */ +.kt { color: #445588; font-weight: bold } /* Keyword.Type */ +.m { color: #009999 } /* Literal.Number */ +.s { color: #bb8844 } /* Literal.String */ +.na { color: #008080 } /* Name.Attribute */ +.nb { color: #999999 } /* Name.Builtin */ +.nc { color: #445588; font-weight: bold } /* Name.Class */ +.no { color: #ff99ff } /* Name.Constant */ +.ni { color: #800080 } /* Name.Entity */ +.ne { color: #990000; font-weight: bold } /* Name.Exception */ +.nf { color: #990000; font-weight: bold } /* Name.Function */ +.nn { color: #555555 } /* Name.Namespace */ +.nt { color: #000080 } /* Name.Tag */ +.nv { color: purple } /* Name.Variable */ +.ow { font-weight: bold } /* Operator.Word */ +.mf { color: #009999 } /* Literal.Number.Float */ +.mh { color: #009999 } /* Literal.Number.Hex */ +.mi { color: #009999 } /* Literal.Number.Integer */ +.mo { color: #009999 } /* Literal.Number.Oct */ +.sb { color: #bb8844 } /* Literal.String.Backtick */ +.sc { color: #bb8844 } /* Literal.String.Char */ +.sd { color: #bb8844 } /* Literal.String.Doc */ +.s2 { color: #bb8844 } /* Literal.String.Double */ +.se { color: #bb8844 } /* Literal.String.Escape */ +.sh { color: #bb8844 } /* Literal.String.Heredoc */ +.si { color: #bb8844 } /* Literal.String.Interpol */ +.sx { color: #bb8844 } /* Literal.String.Other */ +.sr { color: #808000 } /* Literal.String.Regex */ +.s1 { color: #bb8844 } /* Literal.String.Single */ +.ss { color: #bb8844 } /* Literal.String.Symbol */ +.bp { color: #999999 } /* Name.Builtin.Pseudo */ +.vc { color: #ff99ff } /* Name.Variable.Class */ +.vg { color: #ff99ff } /* Name.Variable.Global */ +.vi { color: #ff99ff } /* Name.Variable.Instance */ +.il { color: #009999 } /* Literal.Number.Integer.Long */ \ No newline at end of file diff --git a/docs/_themes/theme.conf b/docs/_themes/theme.conf new file mode 100644 index 0000000..1cc4004 --- /dev/null +++ b/docs/_themes/theme.conf @@ -0,0 +1,4 @@ +[theme] +inherit = basic +stylesheet = nature.css +pygments_style = tango diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..97a513b --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,53 @@ +============== +The public API +============== + +Some examples +------------- + +.. highlight:: python + +Let's import some models and fixtures to play around with. + + >>> from locking.tests.models import Story + >>> from django.contrib.auth.models import User + >>> user = User.objects.all()[0] + >>> story = Story.objects.all()[0] + +Let's lock a story. + + >>> story.lock_for(user) + INFO:root:Attempting to initiate a lock for user `stdbrouw` + INFO:root:Initiated a lock for `stdbrouw` at 2010-06-01 09:33:46.540376 + # We can access all kind of information about the lock + >>> s.locked_at + datetime.datetime(2010, 6, 1, 9, 38, 3, 101238) + >>> s.locked_by + + >>> s.is_locked + True + >>> s.lock_seconds_remaining + 1767 + # Remember: a lock isn't actually active until we save it to the database! + >>> s.save() + +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. + + >>> s.unlock_for(user) + INFO:root:Attempting to open up a lock on `Story object` by user `blub` + INFO:root:Attempting to initiate a lock for user `False` + INFO:root:Freed lock on `Story object` + True + >>> s.save() + +.. __: http://github.com/stdbrouw + +Methods and attributes +---------------------- + +... are not quite documented yet. Expect this soon, and `badger me`__ about it if it's taking too long. + +.. .. automodule:: locking.models + :show-inheritance: + :members: + :undoc-members: \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..a62b29c --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,198 @@ +# -*- coding: utf-8 -*- +# +# django-locking documentation build configuration file, created by +# sphinx-quickstart on Fri May 28 11:02:44 2010. +# +# This file is execfile()d with the current directory set to its containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys, os +# explicitly add this package to the python path so that sphinx.ext.autodoc picks it up +root = os.path.realpath(os.path.join(os.path.abspath(os.path.dirname(__file__)), '..', '..')) +sys.path.append(root) +os.environ['DJANGO_SETTINGS_MODULE'] = 'django.conf.global_settings' + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.append(os.path.abspath('.')) + +# -- General configuration ----------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.coverage'] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'django-locking' +copyright = u'2010, Stijn Debrouwere' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '0.2' +# The full version, including alpha/beta/rc tags. +release = '0.2' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of documents that shouldn't be included in the build. +#unused_docs = [] + +# List of directories, relative to source directory, that shouldn't be searched +# for source files. +exclude_trees = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'tango' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + + +# -- Options for HTML output --------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. Major themes that come with +# Sphinx are currently 'default' and 'sphinxdoc'. +html_theme = 'nature' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +html_theme_path = ['_themes'] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_use_modindex = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = '' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'django-lockingdoc' + + +# -- Options for LaTeX output -------------------------------------------------- + +# The paper size ('letter' or 'a4'). +#latex_paper_size = 'letter' + +# The font size ('10pt', '11pt' or '12pt'). +#latex_font_size = '10pt' + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass [howto/manual]). +latex_documents = [ + ('index', 'django-locking.tex', u'django-locking Documentation', + u'Stijn Debrouwere', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# Additional stuff for the LaTeX preamble. +#latex_preamble = '' + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_use_modindex = True diff --git a/docs/credits.rst b/docs/credits.rst new file mode 100644 index 0000000..9e6df8f --- /dev/null +++ b/docs/credits.rst @@ -0,0 +1,19 @@ +================== +About this project +================== + +License +------- + +``django-locking`` is released under a simplified BSD license, with the exception of two bits of included software: + +* jQuery, which is dual-licensed (GPL and MIT) +* the jQuery URL Parser which also has an MIT-like license +* icons by Mark James (thanks, Mark!) that are CC-licensed (Attribution 2.5) + +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. + +Credits +------- + +``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. \ No newline at end of file diff --git a/docs/design.rst b/docs/design.rst new file mode 100644 index 0000000..16da238 --- /dev/null +++ b/docs/design.rst @@ -0,0 +1,27 @@ +===================== +Design considerations +===================== + +Pessimistic versus optimistic locking +------------------------------------- + +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. + +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 + +* a user's browser crashes before he navigates away from the page +* when a user leaves an edit screen open in a neglected tab +* the user navigates to another website without first saving + +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. + +A short overview of different locking implementations +----------------------------------------------------- + +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. + +While soft locking may seem a little weird, it actually has a bunch of benefits. 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. + +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**. + +``django-locking`` currently does not support hard locks or database-level locks. Hard locks will be implemented soon (they're trivial to add). 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. E.g. on MySQL ``InnoDB`` tables do, but ``MyISAM`` tables don't; sqlite has no row-level locking whatsoever but PostgreSQL does. \ No newline at end of file diff --git a/docs/developers.rst b/docs/developers.rst new file mode 100644 index 0000000..8afdb27 --- /dev/null +++ b/docs/developers.rst @@ -0,0 +1,37 @@ +Developers' documentation +========================= + +The public API +-------------- + +``django-locking`` has a concise API, revolving around the ``LockableModel``. You can read more about how to interact with this API in :doc:`api`. + +Running the test suite +---------------------- + +If you want to run the test suite to ``django-locking``, just, eh, wait 'till I've had a chance to write this part of the documentation. + +Building the documentation +-------------------------- + +Building the documentation can be done by simply 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__. + +.. __: http://sphinx.pocoo.org/index.html + +.. __: http://sphinx.pocoo.org/rest.html + +Help out +-------- + +If you'd like to help out with further development: fork away! + +Design and other resources +-------------------------- + +You can learn a bit more about the rationale behind how ``django-locking`` works over at :doc:`design`. + +You might also want to check out these web pages and see what kind of locking solutions are already out there: + +* http://www.reddit.com/r/django/comments/c8ts2/edit_locking_in_the_admin_anyone_ever_done_this/ +* http://stackoverflow.com/questions/698950/what-is-the-simplest-way-to-lock-an-object-in-django +* http://djangosnippets.org/tags/lock/ \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..d9043eb --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,94 @@ +Concurrency control with django-locking +======================================= + +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: + +.. toctree:: + :glob: + + * + +Read more about this project at :doc:`credits`. + +Avoiding concurrent edits +------------------------- + +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. ``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.** + +.. image:: screenshots/locked-list.png + +``django-locking`` **interfaces with the django admin** application, but **also provides an API** that you can use in applications of your own. + +Looking for something else? +''''''''''''''''''''''''''' + +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. + +.. __: http://code.google.com/p/django-lock/ + +.. __: http://github.com/f4nt/django-granular-permissions + +Beta-quality +'''''''''''' + +While ``django-locking`` has seen a little production use and has a fair amount of unit-tests, please be advised that at this moment it is still beta-quality software. However, I'm quite responsive when it comes to the `GitHub issue tracker`__, and you should also feel free to contact me more directly, either `through GitHub or by e-mailing`__. + +.. __: http://github.com/stdbrouw/django-locking/issues + +.. __: http://github.com/stdbrouw + +Features +-------- + +* admin integration +* 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 +* lock expiration: leaving a browser window open doesn't lock up content indefinitely +* other users can still view locked content, they just can't edit the stuff somebody else is working on +* configurable: you can define the amount of minutes before the app auto-expires content locks +* users receive an alert when a lock is about to expire +* A public API for coders who want to integrate their apps with ``django-locking``. See :doc:`developers` and :doc:`api`. +* well-documented + +Soon: + +* currently only supports soft locks (javascript), but soon it'll do hard locks as well (see :doc:`design`) +* manual lock overrides for admins +* some other things (see :doc:`ponies`) + +.. django-locking is carefully unit-tested (X% code coverage with over Y tests that span more than Z lines of code). Developers expanding on the code or debugging will appreciate that most of its functionality is log-enabled, so you can see what's going on behind the screen. + +.. Additionally, it has a public API, is carefully unit-tested and is log-enabled so you can see what's going on behind the screen. + +Installation +------------ + +1. Downloading and installing the app and its dependencies (django-staticfiles, simplejson) +2. adding the app to settings.py, and optionally specifying a LOCK_TIMEOUT +3. configure ``django-staticfiles file serving`` for development (see the documentation here__) +4. Adding locking to your models, updating the database models +5. Activating locking in the admin. +6. Using the locking API +7. Using django-locking in production (django-staticfiles) + +.. __: http://bitbucket.org/jezdez/django-staticfiles/src#serving-static-files-during-development + +Usage +----- + +Once you've installed ``django-locking`` and have one or a few models that have ``LockableModel`` as a base class, you're set to go. + +.. image:: screenshots/locked-editscreen.png + +Below are a few guidelines for advanced usage. + +* By default, ``django-locking`` uses **soft locks**. Read more about different methods of locking over at :doc:`design`. +* When integrating with your own applications, you should take care when overriding certain methods, specifically ``LockableModel.save`` and ``LockableAdmin.changelist_view``. Make sure to call ``super`` if you want to maintain the default behavior of ``django-locking``. + +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: + +.. __: http://fuhm.net/super-harmful/ + +:: + + def save(*vargs, **kwargs): + super(self.__class__, self).save(*vargs, **kwargs) \ No newline at end of file diff --git a/docs/pip-log.txt b/docs/pip-log.txt new file mode 100644 index 0000000..955f7cf --- /dev/null +++ b/docs/pip-log.txt @@ -0,0 +1,47 @@ +Downloading/unpacking sphinx-ext-autodoc + Getting page http://pypi.python.org/simple/sphinx-ext-autodoc + Could not fetch URL http://pypi.python.org/simple/sphinx-ext-autodoc: HTTP Error 404: Not Found + Will skip URL http://pypi.python.org/simple/sphinx-ext-autodoc when looking for download links for sphinx-ext-autodoc + Getting page http://pypi.python.org/simple/ + URLs to search for versions for sphinx-ext-autodoc: + * http://pypi.python.org/simple/sphinx-ext-autodoc/ + Getting page http://pypi.python.org/simple/sphinx-ext-autodoc/ + Could not fetch URL http://pypi.python.org/simple/sphinx-ext-autodoc/: HTTP Error 404: Not Found + Will skip URL http://pypi.python.org/simple/sphinx-ext-autodoc/ when looking for download links for sphinx-ext-autodoc + Could not find any downloads that satisfy the requirement sphinx-ext-autodoc +No distributions at all found for sphinx-ext-autodoc +Exception information: +Traceback (most recent call last): + File "/Library/Python/2.6/site-packages/pip.py", line 274, in main + self.run(options, args) + File "/Library/Python/2.6/site-packages/pip.py", line 431, in run + requirement_set.install_files(finder, force_root_egg_info=self.bundle) + File "/Library/Python/2.6/site-packages/pip.py", line 1813, in install_files + url = finder.find_requirement(req_to_install, upgrade=self.upgrade) + File "/Library/Python/2.6/site-packages/pip.py", line 1086, in find_requirement + raise DistributionNotFound('No distributions at all found for %s' % req) +DistributionNotFound: No distributions at all found for sphinx-ext-autodoc +------------------------------------------------------------ +/usr/local/bin/pip run on Tue Jun 1 15:31:20 2010 +Downloading/unpacking sphinx-ext-autodoc + Getting page http://pypi.python.org/simple/sphinx-ext-autodoc + Could not fetch URL http://pypi.python.org/simple/sphinx-ext-autodoc: HTTP Error 404: Not Found + Will skip URL http://pypi.python.org/simple/sphinx-ext-autodoc when looking for download links for sphinx-ext-autodoc + Getting page http://pypi.python.org/simple/ +Exception: +Traceback (most recent call last): + File "/Library/Python/2.6/site-packages/pip.py", line 274, in main + self.run(options, args) + File "/Library/Python/2.6/site-packages/pip.py", line 431, in run + requirement_set.install_files(finder, force_root_egg_info=self.bundle) + File "/Library/Python/2.6/site-packages/pip.py", line 1813, in install_files + url = finder.find_requirement(req_to_install, upgrade=self.upgrade) + File "/Library/Python/2.6/site-packages/pip.py", line 1044, in find_requirement + url_name = self._find_url_name(Link(self.index_urls[0]), url_name, req) or req.url_name + File "/Library/Python/2.6/site-packages/pip.py", line 1132, in _find_url_name + for link in page.links: + File "/Library/Python/2.6/site-packages/pip.py", line 2285, in links + url = self.clean_link(urlparse.urljoin(self.url, url)) + File "/Library/Python/2.6/site-packages/pip.py", line 2331, in clean_link + lambda match: '%%%2x' % ord(match.group(0)), url) +KeyboardInterrupt diff --git a/docs/ponies.rst b/docs/ponies.rst new file mode 100644 index 0000000..1967a8b --- /dev/null +++ b/docs/ponies.rst @@ -0,0 +1,19 @@ +======= +Roadmap +======= + +Things that are planned +----------------------- + +* hard locks (see :doc:`design`) +* manual overrides by admins +* enhance the warning dialog users see five minutes prior to expiry, to allow users to renew their lock +* make it so that locks do not trigger the ``auto_now`` or ``auto_now_add`` behavior of DateFields and DateTimeFields + +Someday/maybe +------------- + +* minimize dependence on javascript for soft locks, by using a middleware and Django's 1.2 ``read_only_fields``. ``django-locking`` won't be degrade entirely gracefully, but we do want to make sure it doesn't degrade quite so *ungracefully* as it does now. +* 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__) + +.. __: http://south.aeracode.org/ \ No newline at end of file diff --git a/docs/screenshots/locked-editscreen.png b/docs/screenshots/locked-editscreen.png new file mode 100644 index 0000000..5ccde8d Binary files /dev/null and b/docs/screenshots/locked-editscreen.png differ diff --git a/docs/screenshots/locked-list-by-me.png b/docs/screenshots/locked-list-by-me.png new file mode 100644 index 0000000..b3f0d56 Binary files /dev/null and b/docs/screenshots/locked-list-by-me.png differ diff --git a/docs/screenshots/locked-list.png b/docs/screenshots/locked-list.png new file mode 100644 index 0000000..def813f Binary files /dev/null and b/docs/screenshots/locked-list.png differ diff --git a/locale/vertalingen.txt b/locale/vertalingen.txt new file mode 100644 index 0000000..210e854 --- /dev/null +++ b/locale/vertalingen.txt @@ -0,0 +1,5 @@ +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. + +

Dit artikel wordt momenteel door %(user)s aangepast. Je kan het lezen maar niet bewerken.

+ +Nog %s minuten gesloten voor %s \ No newline at end of file diff --git a/media/locking/css/locking.css b/media/locking/css/locking.css new file mode 100644 index 0000000..f0abc04 --- /dev/null +++ b/media/locking/css/locking.css @@ -0,0 +1,7 @@ +p.is_locked { + border: 2px solid #f00; + padding: 5px; + background: #fee; + display: inline-block; + font-weight: bold; +} \ No newline at end of file diff --git a/media/locking/img/lock.png b/media/locking/img/lock.png new file mode 100755 index 0000000..2ebc4f6 Binary files /dev/null and b/media/locking/img/lock.png differ diff --git a/media/locking/img/page_edit.png b/media/locking/img/page_edit.png new file mode 100755 index 0000000..046811e Binary files /dev/null and b/media/locking/img/page_edit.png differ diff --git a/media/locking/js/admin.locking.js b/media/locking/js/admin.locking.js new file mode 100644 index 0000000..e7d07f8 --- /dev/null +++ b/media/locking/js/admin.locking.js @@ -0,0 +1,48 @@ +/* +FUTURE REFACTOR: 1.2 makes it easy to make fields read-only with +the readonly_fields attribute on ModelAdmin. When 1.2 adoption is +more wide-spread, we could make a lot of this javascript superfluous +*/ + +var app = $.url.segment(1) +var model = $.url.segment(2) +var id = $.url.segment(3) +var base_url = locking.base_url + "/" + [app, model, id].join("/") + +function warning () { + var minutes = locking.timeout/60; + alert(interpolate(gettext("Your lock on this content will expire in a bit less than five minutes. Please save your content and navigate back to this edit page to close the content again for another %s minutes."), minutes)) +} + +function locking_mechanism () { + // locking is pointless when the user is adding a new piece of content + if (id == 'add') return + // we disable all input fields pre-emptively, and subsequently check if the content + // is or is not available for editing + $(":input").attr("disabled", "disabled") + $.getJSON(base_url + "/is_locked/", function(lock, status) { + if (lock.applies && status != '404') { + var notice = interpolate(gettext('

This content is currently being edited by %(for_user)s. You can read it but not edit it.

'), lock, true) + $("#content-main").prepend(notice) + } else { + $(":input").removeAttr("disabled") + $.get(base_url + "/lock/") + $(window).unload(function(){ + // We have to assure that our unlock request actually gets + // through before the user leaves the page, so it shouldn't + // run asynchronously. + $.ajax({'url': base_url + "/unlock/", 'async': false}) + }) + } + }) + + // We give users a warning that their lock is about to expire, + // five minutes before it actually does. + setTimeout(1000*(locking.timeout-300), warning) +} + +$(document).ready(function(){ + if ($("body").hasClass("change-form")) { + locking_mechanism() + } +}) \ No newline at end of file diff --git a/media/locking/js/jquery.url.packed.js b/media/locking/js/jquery.url.packed.js new file mode 100644 index 0000000..14ae800 --- /dev/null +++ b/media/locking/js/jquery.url.packed.js @@ -0,0 +1 @@ +jQuery.url=function(){var segments={};var parsed={};var options={url:window.location,strictMode:false,key:["source","protocol","authority","userInfo","user","password","host","port","relative","path","directory","file","query","anchor"],q:{name:"queryKey",parser:/(?:^|&)([^&=]*)=?([^&]*)/g},parser:{strict:/^(?:([^:\/?#]+):)?(?:\/\/((?:(([^:@]*):?([^:@]*))?@)?([^:\/?#]*)(?::(\d*))?))?((((?:[^?#\/]*\/)*)([^?#]*))(?:\?([^#]*))?(?:#(.*))?)/,loose:/^(?:(?![^:@]+:[^:@\/]*@)([^:\/?#.]+):)?(?:\/\/)?((?:(([^:@]*):?([^:@]*))?@)?([^:\/?#]*)(?::(\d*))?)(((\/(?:[^?#](?![^?#\/]*\.[^?#\/.]+(?:[?#]|$)))*\/?)?([^?#\/]*))(?:\?([^#]*))?(?:#(.*))?)/}};var parseUri=function(){str=decodeURI(options.url);var m=options.parser[options.strictMode?"strict":"loose"].exec(str);var uri={};var i=14;while(i--){uri[options.key[i]]=m[i]||""}uri[options.q.name]={};uri[options.key[12]].replace(options.q.parser,function($0,$1,$2){if($1){uri[options.q.name][$1]=$2}});return uri};var key=function(key){if(!parsed.length){setUp()}if(key=="base"){if(parsed.port!==null&&parsed.port!==""){return parsed.protocol+"://"+parsed.host+":"+parsed.port+"/"}else{return parsed.protocol+"://"+parsed.host+"/"}}return(parsed[key]==="")?null:parsed[key]};var param=function(item){if(!parsed.length){setUp()}return(parsed.queryKey[item]===null)?null:parsed.queryKey[item]};var setUp=function(){parsed=parseUri();getSegments()};var getSegments=function(){var p=parsed.path;segments=[];segments=parsed.path.length==1?{}:(p.charAt(p.length-1)=="/"?p.substring(1,p.length-1):path=p.substring(1)).split("/")};return{setMode:function(mode){strictMode=mode=="strict"?true:false;return this},setUrl:function(newUri){options.url=newUri===undefined?window.location:newUri;setUp();return this},segment:function(pos){if(!parsed.length){setUp()}if(pos===undefined){return segments.length}return(segments[pos]===""||segments[pos]===undefined)?null:segments[pos]},attr:key,param:param}}(); \ No newline at end of file diff --git a/models.py b/models.py new file mode 100644 index 0000000..4edc28f --- /dev/null +++ b/models.py @@ -0,0 +1,112 @@ +# encoding: utf-8 + +from datetime import datetime + +from django.db import models +from django.conf import settings +from django.contrib.auth import models as auth + +from locking import LOCK_TIMEOUT, logging + +class LockableModel(models.Model): + class Meta: + abstract = True + + # voorzieningen die concurrent editing onmogelijk maken + locked_at = models.DateTimeField(null=True, editable=False) + locked_by = models.ForeignKey(auth.User, related_name="working_on_%(class)s", null=True, editable=False) + + @property + def is_locked(self): + """ + docstring todo + """ + if isinstance(self.locked_at, datetime): + # artikels worden een half uur gesloten + if (datetime.today() - self.locked_at).seconds < LOCK_TIMEOUT: + return True + else: + return False + return False + + @property + def lock_seconds_remaining(self): + """ + docstring todo + """ + return LOCK_TIMEOUT - (datetime.today() - self.locked_at).seconds + + """ + test + """ + def lock_for(self, user=False, hard_lock=False): + """ + docstring todo + """ + logging.info("Attempting to initiate a lock for user `%s`" % user) + + # the save method unlocks the object, but of course + # it should keep things nicely locked when we're only + # saving to apply the lock! + self._initiate_lock = True + + # False om te openen, een geldige user om te sluiten + if isinstance(user, auth.User): + self.locked_at = datetime.today() + self.locked_by = user + logging.info("Initiated a lock for `%s` at %s" % (self.locked_by, self.locked_at)) + elif user is False: + logging.info("Freed lock on `%s`" % self) + # Eh, somewhat shoddy as far as exception handling goes. Should clean this up. + try: + self.locked_at = self.locked_by = None + except: + pass + + def unlock_for(self, user): + """ + docstring todo + """ + logging.info("Attempting to open up a lock on `%s` by user `%s`" % (self, user)) + + # refactor: should raise exceptions instead + if self.is_locked_by(user): + self.unlock() + return True + else: + return False + + def unlock(self): + """ + docstring todo + """ + # TO FIX: dit gaat niet werken, waar! + self.lock_for(False) + + def lock_applies_to(self, user): + """ + docstring todo + """ + logging.info("Checking if the lock on `%s` applies to user `%s`" % (self, user)) + # a lock does not apply to the person who initiated the lock + if self.is_locked and self.locked_by != user: + logging.info("Lock applies.") + return True + else: + logging.info("Lock does not apply.") + return False + + def is_locked_by(self, user): + """ + docstring todo + """ + return user == self.locked_by + + def save(self, *vargs, **kwargs): + # see _set_lock: we don't disengage the lock if the + # entire point of this save is to engage the lock + if getattr(self, '_initiate_lock', False) is False: + logging.info("Saving `%s` and disengaging any existing lock, if necessary." % self) + self.unlock() + + super(LockableModel, self).save(*vargs, **kwargs) \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/admin.py b/tests/admin.py new file mode 100644 index 0000000..3e5ca3f --- /dev/null +++ b/tests/admin.py @@ -0,0 +1,11 @@ +# encoding: utf-8 + +from django.contrib import admin +from locking.tests import models +from locking.admin import LockableAdmin + +class StoryAdmin(LockableAdmin): + list_display = ('lock', 'content', ) + list_display_links = ('content', ) + +admin.site.register(models.Story, StoryAdmin) \ No newline at end of file diff --git a/tests/fixtures/locking_scenario.json b/tests/fixtures/locking_scenario.json new file mode 100644 index 0000000..93bb8f2 --- /dev/null +++ b/tests/fixtures/locking_scenario.json @@ -0,0 +1,163 @@ +[ + { + "pk": 5, + "model": "contenttypes.contenttype", + "fields": { + "model": "contenttype", + "name": "content type", + "app_label": "contenttypes" + } + }, + { + "pk": 2, + "model": "contenttypes.contenttype", + "fields": { + "model": "group", + "name": "group", + "app_label": "auth" + } + }, + { + "pk": 9, + "model": "contenttypes.contenttype", + "fields": { + "model": "logentry", + "name": "log entry", + "app_label": "admin" + } + }, + { + "pk": 4, + "model": "contenttypes.contenttype", + "fields": { + "model": "message", + "name": "message", + "app_label": "auth" + } + }, + { + "pk": 1, + "model": "contenttypes.contenttype", + "fields": { + "model": "permission", + "name": "permission", + "app_label": "auth" + } + }, + { + "pk": 6, + "model": "contenttypes.contenttype", + "fields": { + "model": "session", + "name": "session", + "app_label": "sessions" + } + }, + { + "pk": 7, + "model": "contenttypes.contenttype", + "fields": { + "model": "site", + "name": "site", + "app_label": "sites" + } + }, + { + "pk": 8, + "model": "contenttypes.contenttype", + "fields": { + "model": "story", + "name": "story", + "app_label": "tests" + } + }, + { + "pk": 3, + "model": "contenttypes.contenttype", + "fields": { + "model": "user", + "name": "user", + "app_label": "auth" + } + }, + { + "pk": 1, + "model": "sites.site", + "fields": { + "domain": "example.com", + "name": "example.com" + } + }, + { + "pk": 1, + "model": "tests.story", + "fields": { + "content": "This is a little story.", + "locked_at": "2010-05-28 11:10:05", + "locked_by": 2 + } + }, + { + "pk": 22, + "model": "auth.permission", + "fields": { + "codename": "add_story", + "name": "Can add story", + "content_type": 8 + } + }, + { + "pk": 23, + "model": "auth.permission", + "fields": { + "codename": "change_story", + "name": "Can change story", + "content_type": 8 + } + }, + { + "pk": 24, + "model": "auth.permission", + "fields": { + "codename": "delete_story", + "name": "Can delete story", + "content_type": 8 + } + }, + { + "pk": 1, + "model": "auth.user", + "fields": { + "username": "stdbrouw", + "first_name": "", + "last_name": "", + "is_active": true, + "is_superuser": true, + "is_staff": true, + "last_login": "2010-05-28 07:03:47", + "groups": [], + "user_permissions": [], + "password": "sha1$db580$7169260d47c8d33756c8ad6afedae4a8935b0d26", + "email": "stijn@typograaf.be", + "date_joined": "2010-05-28 04:54:28" + } + }, + { + "pk": 2, + "model": "auth.user", + "fields": { + "username": "blub", + "first_name": "", + "last_name": "", + "is_active": true, + "is_superuser": false, + "is_staff": false, + "last_login": "2010-05-28 10:22:49", + "groups": [], + "user_permissions": [], + "password": "", + "email": "", + "date_joined": "2010-05-28 10:22:49" + } + } +] \ No newline at end of file diff --git a/tests/models.py b/tests/models.py new file mode 100644 index 0000000..b905534 --- /dev/null +++ b/tests/models.py @@ -0,0 +1,14 @@ + +from django.db import models +from locking import models as locking + +class Story(locking.LockableModel): + content = models.TextField(blank=True) + + class Meta: + verbose_name_plural = 'stories' + +class Unlockable(models.Model): + # this model serves to test that utils.gather_lockable_models + # actually does what it's supposed to + content = models.TextField(blank=True) \ No newline at end of file diff --git a/tests/tests.py b/tests/tests.py new file mode 100644 index 0000000..7cdc546 --- /dev/null +++ b/tests/tests.py @@ -0,0 +1,168 @@ +import simplejson + +from django.core.urlresolvers import reverse +from django.test.client import Client +from django.contrib.auth.models import User + +from locking import models, views, LOCK_TIMEOUT +from locking.tests.utils import TestCase +from locking.tests.models import Story + + + +class AppTests(TestCase): + def setUp(self): + self.story = Story.objects.all()[0] + users = User.objects.all() + self.user = users[0] + self.alt_user = users[1] + + def tearDown(self): + pass + # flush & load fixtures opnieuw + + def test_decorator_user_may_change_model(self): + raise NotImplementedError + + def test_decorator_is_lockable(self): + raise NotImplementedError + + def test_lock_for(self): + self.story.lock_for(self.user) + self.assertTrue(self.story.is_locked) + self.story.save() + self.assertTrue(self.story.is_locked) + + def test_lock_for_overwrite(self): + # we shouldn't be able to overwrite a lock by another user + self.story.lock_for(self.alt_user) + self.story.lock_for(self.user) + self.assertTrue(self.locked_by, self.alt_user) + + def test_unlock_for_self(self): + self.story.lock_for(self.user) + unlocked = self.story.unlock_for(self.user) + # assertException? of met een catch zodat we de exception vangen? + self.assertTrue(unlocked) + + def test_unlock_for_disallowed(self): + self.story.lock_for(self.alt_user) + unlocked = self.story.unlock_for(self.user) + # assertException? of met een catch zodat we de exception vangen? + self.assertFalse(unlocked) + + def test_unlock(self): + raise NotImplementedError + + def test_lock_applies_to(self): + self.story.lock_for(self.alt_user) + applies = self.story.lock_applies_to(self.user) + self.assertTrue(applies) + + def test_lock_doesnt_apply_to(self): + self.story.lock_for(self.user) + applies = self.story.lock_applies_to(self.user) + self.assertFalse(applies) + + def test_is_locked_by(self): + self.story.lock_for(self.user) + self.assertEquals(self.story.locked_by, self.user) + + def test_is_unlocked(self): + self.assertFalse(self.story.is_locked) + + def test_unlock_upon_save(self): + self.story.lock_for(self.user) + self.story.save() + self.assertFalse(self.story.is_locked) + + def test_gather_lockable_models(self): + from locking import utils + from locking.tests import models + lockable_models = utils.gather_lockable_models() + self.assertTrue(models.Story in lockable_models) + self.assertTrue(models.Unlockable not in lockable_models) + +class BrowserTests(TestCase): + apps = ('locking.tests', 'django.contrib.auth', 'django.contrib.admin', ) + + def login(self): + self.c.post('/admin/login/', {}) + + def logout(self): + self.c.logout() + + def setUp(self): + self.c = Client() + self.c.post('/login/', {'name': 'fred', 'passwd': 'secret'}) + self.urls = { + "lock": reverse(views.lock), + "unlock": reverse(views.unlock), + "is_locked": reverse(views.is_locked), + "js_variables": reverse(views.js_variables), + } + + def tearDown(self): + pass + + def test_access_when_cannot_change_model(self): + raise NotImplementedError + + def test_lock_when_allowed(self): + + self.c.get(self.urls['js_variables']) + self.assertStatusCode(response) + + def test_lock_when_logged_out(self): + self.logout() + response = self.c.get(self.urls['lock']) + self.assertStatusCode(response, 401) + + def test_lock_when_disallowed(self): + self.assertStatusCode(response, 403) + + def test_unlock_when_allowed(self): + self.assertStatusCode(response, 200) + + def test_unlock_when_disallowed(self): + self.assertStatusCode(response, 403) + + def test_unlock_when_transpired(self): + self.assertStatusCode(response, 403) + + def test_is_locked_when_applies(self): + response = None + self.assertStatusCode(response, 200) + res = simplejson.loads(response) + self.assertTrue(res['applies']) + self.assertTrue(res['is_active']) + + def test_is_locked_when_self(self): + response = None + self.assertStatusCode(response, 200) + res = simplejson.loads(response) + self.assertFalse(res['applies']) + self.assertTrue(res['is_active']) + + def test_js_variables(self): + response = self.c.get(self.urls['js_variables']) + self.assertStatusCode(response, 200) + self.assertContains(response, LOCK_TIMEOUT) + + def test_admin_media(self): + res = self.get('todo') + self.assertContains(res, 'admin.locking.js') + + def test_admin_listedit_when_locked(self): + # testen dat de listedit een locking-icoontje & de andere + # boel weergeeft als een story op slot is + res = None + self.assertContains(res, 'locking/img/lock.png') + + def test_admin_listedit_when_locked_self(self): + res = None + self.assertContains(res, 'locking/img/page_edit.png') + + def test_admin_listedit_when_unlocked(self): + res = None + self.assertNotContains(res, 'locking/img') \ No newline at end of file diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..118963d --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,26 @@ +# encoding: utf-8 + +from django.conf import settings +from django.core.management import call_command +from django.db.models import loading +from django import test + +class TestCase(test.TestCase): + apps = () + + def _pre_setup(self): + # Add the models to the db. + self._original_installed_apps = list(settings.INSTALLED_APPS) + for app in self.apps: + settings.INSTALLED_APPS.append(app) + loading.cache.loaded = False + call_command('syncdb', interactive=False, verbosity=0) + # Call the original method that does the fixtures etc. + super(TestCase, self)._pre_setup() + + def _post_teardown(self): + # Call the original method. + super(TestCase, self)._post_teardown() + # Restore the settings. + settings.INSTALLED_APPS = self._original_installed_apps + loading.cache.loaded = False \ No newline at end of file diff --git a/urls.py b/urls.py new file mode 100644 index 0000000..f7388e8 --- /dev/null +++ b/urls.py @@ -0,0 +1,13 @@ +from django.conf.urls.defaults import * + +urlpatterns = patterns('locking.views', + # verwijst naar een ajax-view voor het lockingmechanisme + (r'(?P[\w-]+)/(?P[\w-]+)/(?P\d+)/lock/$', 'lock'), + (r'(?P[\w-]+)/(?P[\w-]+)/(?P\d+)/unlock/$', 'unlock'), + (r'(?P[\w-]+)/(?P[\w-]+)/(?P\d+)/is_locked/$', 'is_locked'), + (r'variables\.js$', 'js_variables', {}, 'locking_variables'), + ) + +urlpatterns += patterns('', + (r'jsi18n/$', 'django.views.i18n.javascript_catalog', {'packages': 'locking'}), + ) \ No newline at end of file diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..117c7ba --- /dev/null +++ b/utils.py @@ -0,0 +1,17 @@ +# encoding: utf-8 + +from django.contrib.contenttypes.models import ContentType +from locking.models import LockableModel + +def gather_lockable_models(): + lockable_models = dict() + for contenttype in ContentType.objects.all(): + model = contenttype.model_class() + # there might be a None value betwixt our content types + if model: + app = model._meta.app_label + name = model._meta.module_name + if issubclass(model, LockableModel): + lockable_models.setdefault(app, {}) + lockable_models[app][name] = model + return lockable_models \ No newline at end of file diff --git a/views.py b/views.py new file mode 100644 index 0000000..b47cee2 --- /dev/null +++ b/views.py @@ -0,0 +1,69 @@ +import simplejson + +from django.http import HttpResponse +from django.core.urlresolvers import reverse + +from locking.decorators import user_may_change_model, is_lockable, log +from locking import utils, LOCK_TIMEOUT, logging + +""" +These views are called from javascript to open and close assets (objects), in order +to prevent concurrent editing. +""" + +lockable_models = utils.gather_lockable_models() + +@log +@user_may_change_model +@is_lockable +def lock(request, app, model, id): + logging.info('hallo daar') + + obj = lockable_models[app][model].objects.get(pk=id) + # geen bestaande sloten overschrijven, tenzij als het komt van de gebruiker + # die het huidige slot heeft geactiveerd + if obj.lock_applies_to(request.user): + return HttpResponse(status=403) + else: + obj.lock_for(request.user) + obj.save() + return HttpResponse(status=200) + +@log +@user_may_change_model +@is_lockable +def unlock(request, app, model, id): + obj = lockable_models[app][model].objects.get(pk=id) + + # Users who don't have exclusive access to an object anymore may still + # request we unlock an object. This happens e.g. when a user navigates + # away from an edit screen that's been open for very long. + # When this happens, we just ignore the request. That way, any new lock + # that may since have been put in place by another user won't be + # unlocked. + if obj.unlock_for(request.user): + obj.save() + return HttpResponse(status=200) + else: + return HttpResponse(status=403) + +@log +@user_may_change_model +@is_lockable +def is_locked(request, app, model, id): + obj = lockable_models[app][model].objects.get(pk=id) + + response = simplejson.dumps({ + "is_active": obj.is_locked, + "for_user": getattr(obj.locked_by, 'username', None), + "applies": obj.lock_applies_to(request.user), + }) + return HttpResponse(response) + +@log +def js_variables(request): + response = "var locking = " + simplejson.dumps({ + 'base_url': "/".join(request.path.split('/')[:-1]), + 'timeout': LOCK_TIMEOUT, + }) + return HttpResponse(response) \ No newline at end of file