├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── Makefile ├── _themes │ ├── nature │ │ ├── static │ │ │ ├── nature.css_t │ │ │ └── pygments.css │ │ └── theme.conf │ ├── static │ │ ├── nature.css_t │ │ └── pygments.css │ └── theme.conf ├── api.rst ├── changelog.rst ├── conf.py ├── credits.rst ├── design.rst ├── developers.rst ├── index.rst ├── pip-log.txt ├── ponies.rst └── screenshots │ ├── auto_unlock.png │ ├── expire_status.png │ ├── hard_lock.png │ ├── lock_by_who.png │ ├── locked-editscreen.png │ ├── locked-list-by-me.png │ ├── locked-list.png │ ├── reload_or_bust.png │ └── unlock_prompt.png ├── locking ├── __init__.py ├── admin.py ├── decorators.py ├── forms.py ├── locale │ └── vertalingen.txt ├── managers.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── settings.py ├── south_migrations │ ├── 0001_initial.py │ ├── 0002_auto__del_field_lock_app__del_field_lock_entry_id__del_field_lock_mode.py │ └── __init__.py ├── static │ └── locking │ │ ├── css │ │ └── locking.css │ │ ├── img │ │ ├── lock.png │ │ └── page_edit.png │ │ └── js │ │ └── admin.locking.js ├── tests │ ├── __init__.py │ ├── admin.py │ ├── fixtures │ │ └── locking_scenario.json │ ├── models.py │ ├── tests.py │ ├── urls.py │ └── utils.py ├── urls.py ├── utils.py └── views.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .DS_Store 3 | docs/_build 4 | *.egg-info 5 | build 6 | dist 7 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | recursive-include locking/static * 4 | -------------------------------------------------------------------------------- /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 | ![unlock prompt](https://github.com/RobCombs/django-locking/raw/master/docs/screenshots/unlock_prompt.png) 26 | 27 | Hover over the lock icon to see when the lock expires 28 | _____________________________________________________ 29 | 30 | ![expire status](https://github.com/RobCombs/django-locking/raw/master/docs/screenshots/expire_status.png) 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 | ![lock_by_who](https://github.com/RobCombs/django-locking/raw/master/docs/screenshots/lock_by_who.png) 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 | ![reload or bust](https://github.com/RobCombs/django-locking/raw/master/docs/screenshots/reload_or_bust.png) 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 | ![hard lock](https://github.com/RobCombs/django-locking/raw/master/docs/screenshots/hard_lock.png) 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 | ![auto unlock](https://github.com/RobCombs/django-locking/raw/master/docs/screenshots/auto_unlock.png) 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 -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/_themes/nature/theme.conf: -------------------------------------------------------------------------------- 1 | [theme] 2 | inherit = basic 3 | stylesheet = nature.css 4 | pygments_style = tango 5 | -------------------------------------------------------------------------------- /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/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/theme.conf: -------------------------------------------------------------------------------- 1 | [theme] 2 | inherit = basic 3 | stylesheet = nature.css 4 | pygments_style = tango 5 | -------------------------------------------------------------------------------- /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**. -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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/screenshots/auto_unlock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-locking/f51583d54bd1a0fa4dfe259086361c74142bb741/docs/screenshots/auto_unlock.png -------------------------------------------------------------------------------- /docs/screenshots/expire_status.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-locking/f51583d54bd1a0fa4dfe259086361c74142bb741/docs/screenshots/expire_status.png -------------------------------------------------------------------------------- /docs/screenshots/hard_lock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-locking/f51583d54bd1a0fa4dfe259086361c74142bb741/docs/screenshots/hard_lock.png -------------------------------------------------------------------------------- /docs/screenshots/lock_by_who.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-locking/f51583d54bd1a0fa4dfe259086361c74142bb741/docs/screenshots/lock_by_who.png -------------------------------------------------------------------------------- /docs/screenshots/locked-editscreen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-locking/f51583d54bd1a0fa4dfe259086361c74142bb741/docs/screenshots/locked-editscreen.png -------------------------------------------------------------------------------- /docs/screenshots/locked-list-by-me.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-locking/f51583d54bd1a0fa4dfe259086361c74142bb741/docs/screenshots/locked-list-by-me.png -------------------------------------------------------------------------------- /docs/screenshots/locked-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-locking/f51583d54bd1a0fa4dfe259086361c74142bb741/docs/screenshots/locked-list.png -------------------------------------------------------------------------------- /docs/screenshots/reload_or_bust.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-locking/f51583d54bd1a0fa4dfe259086361c74142bb741/docs/screenshots/reload_or_bust.png -------------------------------------------------------------------------------- /docs/screenshots/unlock_prompt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-locking/f51583d54bd1a0fa4dfe259086361c74142bb741/docs/screenshots/unlock_prompt.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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /locking/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-locking/f51583d54bd1a0fa4dfe259086361c74142bb741/locking/migrations/__init__.py -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /locking/south_migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-locking/f51583d54bd1a0fa4dfe259086361c74142bb741/locking/south_migrations/__init__.py -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /locking/static/locking/img/lock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-locking/f51583d54bd1a0fa4dfe259086361c74142bb741/locking/static/locking/img/lock.png -------------------------------------------------------------------------------- /locking/static/locking/img/page_edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-locking/f51583d54bd1a0fa4dfe259086361c74142bb741/locking/static/locking/img/page_edit.png -------------------------------------------------------------------------------- /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/__init__.py: -------------------------------------------------------------------------------- 1 | from tests import * -------------------------------------------------------------------------------- /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/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 | ] -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/utils.py: -------------------------------------------------------------------------------- 1 | def timedelta_to_seconds(delta): 2 | return delta.days * 24 * 60 * 60 + delta.seconds 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------