├── .gitignore ├── AUTHORS ├── CONTRIBUTING.md ├── COPYING.txt ├── MANIFEST.in ├── README.markdown ├── TERMS.md ├── setup.py └── src ├── django_nudge.egg-info ├── PKG-INFO ├── SOURCES.txt ├── dependency_links.txt ├── not-zip-safe ├── requires.txt └── top_level.txt └── nudge ├── __init__.py ├── admin.py ├── client.py ├── demo ├── __init__.py ├── admin.py ├── models.py ├── tests.py └── views.py ├── exceptions.py ├── management ├── __init__.py └── commands │ ├── __init__.py │ └── new_nudge_key.py ├── migrations ├── 0001_initial.py └── __init__.py ├── models.py ├── server.py ├── templates ├── 500.html └── admin │ └── nudge │ ├── batch │ ├── _batch_item_row.html │ ├── change_form.html │ ├── push.html │ ├── read_only.html │ └── submit_line.html │ └── index.html ├── templatetags ├── __init__.py ├── nudge_admin_helpers.py └── version_display.py ├── tests.py ├── urls.py ├── utils.py └── views.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | dist/* 3 | *.swp 4 | *~ 5 | MANIFEST 6 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | CFPB 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Public domain 2 | 3 | The project is in the public domain within the United States, and 4 | copyright and related rights in the work worldwide are waived through 5 | the [CC0 1.0 Universal public domain dedication][CC0]. 6 | 7 | All contributions to this project will be released under the CC0 8 | dedication. By submitting a pull request, you are agreeing to comply 9 | with this waiver of copyright interest. 10 | 11 | [CC0]: http://creativecommons.org/publicdomain/zero/1.0/ 12 | -------------------------------------------------------------------------------- /COPYING.txt: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include src/nudge/templates/*.html 2 | include src/nudge/templates/admin/nudge/*.html 3 | include src/nudge/templates/admin/nudge/batch/*.html 4 | include src/nudge/templates/admin/nudge/setting/*.html 5 | include README.markdown -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | Nudge is a Django app that lets you selectively push content from one Django server (we'll call this "staging") to another ("production"). 2 | 3 | When staging code, you might find it necessary to preview that code with real data. You might also use your staging-side Django admin as your primary content creation environment, in order to beef up security on your production end. 4 | 5 | If you do either of these, moving content from staging to production requires copying your entire database from one server to another. Nudge fixes this. Nudge will let you move new and modified content from staging to production. As content is created, deleted, and modified in staging, Nudge will keep track of it. When you are ready to move that content to production, create a batch. Add whatever content necessary to that batch; ignore the content that is not yet ready to be sent to production. Save the batch and deploy it. The content in the batch will be immediately available in your production environment. 6 | 7 | Nudge was inspired by the RAMP plugin for WordPress and wouldn't be possible without the incredible django-reversion version control extension. 8 | 9 | You should understand how Reversion works before proceeding with nudge, and any models you'll want to use with Nudge should be enabled for Reversion. 10 | 11 | https://github.com/etianen/django-reversion 12 | 13 | ## Installation 14 | 15 | 1. Install nudge with 'pip install django-nudge' 16 | 2. add 'nudge' to your INSTALLED_APPS 17 | 3. add nudge to your URL configuration, on your both servers: like: url(r'^nudge-api/', include('nudge.urls')), 18 | 4. run manage.py syncdb 19 | 20 | ## Using Nudge 21 | 22 | 1. Use your favorite method of enabling django-reversion for your models. 23 | 2. generate a key by running ./manage.py new_nudge_key. Set this to NUDGE_KEY in both settings.py (staging and production) 24 | 3. in the staging settings.py, set NUDGE_REMOTE_ADDRESS to the 'nudge-api' URL of your production server 'http://somehost/nudge-api/' 25 | 4. in the staging settings.py, set NUDGE_SELECTIVE to a list any models that you want to use with Nudge (and are already being managed by reversion), like ['jobmanager.job', 'knowledgebase.question'] -------------------------------------------------------------------------------- /TERMS.md: -------------------------------------------------------------------------------- 1 | As a work of the United States Government, this package is in the 2 | public domain within the United States. Additionally, we waive 3 | copyright and related rights in the work worldwide through the CC0 1.0 4 | Universal public domain dedication. 5 | 6 | ## CC0 1.0 Universal Summary 7 | 8 | This is a human-readable summary of the 9 | [Legal Code (read the full text)](http://creativecommons.org/publicdomain/zero/1.0/legalcode). 10 | 11 | ### No Copyright 12 | 13 | The person who associated a work with this deed has dedicated the work to 14 | the public domain by waiving all of his or her rights to the work worldwide 15 | under copyright law, including all related and neighboring rights, to the 16 | extent allowed by law. 17 | 18 | You can copy, modify, distribute and perform the work, even for commercial 19 | purposes, all without asking permission. See Other Information below. 20 | 21 | ### Other Information 22 | 23 | In no way are the patent or trademark rights of any person affected by CC0, 24 | nor are the rights that other persons may have in the work or in how the 25 | work is used, such as publicity or privacy rights. 26 | 27 | Unless expressly stated otherwise, the person who associated a work with 28 | this deed makes no warranties about the work, and disclaims liability for 29 | all uses of the work, to the fullest extent permitted by applicable law. 30 | When using or citing the work, you should not imply endorsement by the 31 | author or the affirmer. 32 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | # Load in babel support, if available. 4 | try: 5 | from babel.messages import frontend as babel 6 | cmdclass = {"compile_catalog": babel.compile_catalog, 7 | "extract_messages": babel.extract_messages, 8 | "init_catalog": babel.init_catalog, 9 | "update_catalog": babel.update_catalog, } 10 | except ImportError: 11 | cmdclass = {} 12 | 13 | setup(name="django-nudge", 14 | version="0.9.1", 15 | description="Use Nudge to (gently) push content between Django servers", 16 | author="Joshua Ruihley, Ross Karchner", 17 | author_email="joshua.ruihley@cfpb.gov", 18 | url="https://github.com/CFPB/django-nudge", 19 | zip_safe=False, 20 | packages=["nudge", "nudge.demo", "nudge.management", "nudge.templatetags", "nudge.management.commands"], 21 | package_data = {"nudge": ["templates/*.html", 22 | "templates/admin/nudge/*.html", 23 | "templates/admin/nudge/batch/*.html", 24 | "templates/admin/nudge/setting/*.html"]}, 25 | package_dir={"": "src"}, 26 | install_requires=['django', 'django-reversion', 'pycrypto',], 27 | cmdclass = cmdclass, 28 | classifiers=["Development Status :: 4 - Beta", 29 | "Environment :: Web Environment", 30 | "Intended Audience :: Developers", 31 | "License :: Public Domain", 32 | "Operating System :: OS Independent", 33 | "Programming Language :: Python", 34 | "Framework :: Django",]) 35 | -------------------------------------------------------------------------------- /src/django_nudge.egg-info/PKG-INFO: -------------------------------------------------------------------------------- 1 | Metadata-Version: 1.1 2 | Name: django-nudge 3 | Version: 0.9.0 4 | Summary: Use Nudge to (gently) push content between Django servers 5 | Home-page: https://github.com/CFPB/django-nudge 6 | Author: Joshua Ruihley, Ross Karchner 7 | Author-email: joshua.ruihley@cfpb.gov 8 | License: UNKNOWN 9 | Description: UNKNOWN 10 | Platform: UNKNOWN 11 | Classifier: Development Status :: 4 - Beta 12 | Classifier: Environment :: Web Environment 13 | Classifier: Intended Audience :: Developers 14 | Classifier: License :: Public Domain 15 | Classifier: Operating System :: OS Independent 16 | Classifier: Programming Language :: Python 17 | Classifier: Framework :: Django 18 | -------------------------------------------------------------------------------- /src/django_nudge.egg-info/SOURCES.txt: -------------------------------------------------------------------------------- 1 | MANIFEST.in 2 | README.markdown 3 | src/django_nudge.egg-info/PKG-INFO 4 | src/django_nudge.egg-info/SOURCES.txt 5 | src/django_nudge.egg-info/dependency_links.txt 6 | src/django_nudge.egg-info/not-zip-safe 7 | src/django_nudge.egg-info/requires.txt 8 | src/django_nudge.egg-info/top_level.txt 9 | src/nudge/__init__.py 10 | src/nudge/admin.py 11 | src/nudge/client.py 12 | src/nudge/exceptions.py 13 | src/nudge/models.py 14 | src/nudge/server.py 15 | src/nudge/tests.py 16 | src/nudge/urls.py 17 | src/nudge/utils.py 18 | src/nudge/views.py 19 | src/nudge/demo/__init__.py 20 | src/nudge/demo/admin.py 21 | src/nudge/demo/models.py 22 | src/nudge/demo/tests.py 23 | src/nudge/demo/views.py 24 | src/nudge/management/__init__.py 25 | src/nudge/management/commands/__init__.py 26 | src/nudge/management/commands/new_nudge_key.py 27 | src/nudge/templates/500.html 28 | src/nudge/templates/admin/nudge/app_index.html 29 | src/nudge/templates/admin/nudge/index.html 30 | src/nudge/templates/admin/nudge/batch/_batch_item_row.html 31 | src/nudge/templates/admin/nudge/batch/change_form.html 32 | src/nudge/templates/admin/nudge/batch/push.html 33 | src/nudge/templates/admin/nudge/batch/read_only.html 34 | src/nudge/templates/admin/nudge/batch/submit_line.html 35 | src/nudge/templatetags/__init__.py 36 | src/nudge/templatetags/nudge_admin_helpers.py 37 | src/nudge/templatetags/version_display.py -------------------------------------------------------------------------------- /src/django_nudge.egg-info/dependency_links.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/django_nudge.egg-info/not-zip-safe: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/django_nudge.egg-info/requires.txt: -------------------------------------------------------------------------------- 1 | django 2 | django-reversion 3 | pycrypto -------------------------------------------------------------------------------- /src/django_nudge.egg-info/top_level.txt: -------------------------------------------------------------------------------- 1 | nudge 2 | -------------------------------------------------------------------------------- /src/nudge/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cfpb/django-nudge/16fbb7ec75620582b486c7a4f6df551083cddcfa/src/nudge/__init__.py -------------------------------------------------------------------------------- /src/nudge/admin.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from django.conf.urls.defaults import patterns, url 3 | from django.contrib import admin 4 | from django.contrib.admin.util import unquote 5 | from django.contrib.auth.admin import csrf_protect_m 6 | from django.db import transaction 7 | from django.forms.formsets import all_valid 8 | from django.http import HttpResponseRedirect 9 | from django.shortcuts import render_to_response 10 | from django.template.response import TemplateResponse 11 | from django.utils.functional import update_wrapper 12 | from django.utils.safestring import mark_safe 13 | from nudge import client 14 | from nudge import utils 15 | from nudge.models import Batch, BatchPushItem, default_batch_start_date 16 | 17 | try: 18 | import simplejson as json 19 | except ImportError: 20 | import json 21 | 22 | 23 | class BatchAdmin(admin.ModelAdmin): 24 | exclude = ['preflight', 'selected_items_packed', 'first_push_attempt'] 25 | 26 | def render_change_form(self, *args, **kwargs): 27 | request, context = args[:2] 28 | batch = context.get('original') 29 | attached_versions = [] 30 | if batch: 31 | context.update({ 32 | 'pushing': bool(batch.preflight), 33 | 'object': batch, 34 | }) 35 | else: 36 | context.update({'editable': True}) 37 | 38 | if batch: 39 | available_changes = [item for item in utils.changed_items( 40 | batch.start_date, batch=batch) 41 | if item not in attached_versions] 42 | else: 43 | available_changes = [item for item in utils.changed_items( 44 | default_batch_start_date()) if item not in attached_versions] 45 | 46 | context.update({'available_changes': available_changes}) 47 | 48 | return super(BatchAdmin, self).render_change_form(*args, **kwargs) 49 | 50 | def change_view(self, request, object_id, extra_context=None): 51 | obj = self.get_object(request, unquote(object_id)) 52 | if obj.preflight: 53 | return HttpResponseRedirect('push/') 54 | if '_save_and_push' not in request.POST: 55 | return super(BatchAdmin, self).change_view( 56 | request, object_id, extra_context=extra_context) 57 | else: 58 | ModelForm = self.get_form(request, obj) 59 | formsets = [] 60 | form = ModelForm(request.POST, request.FILES, instance=obj) 61 | if form.is_valid(): 62 | form_validated = True 63 | new_object = self.save_form(request, form, change=True) 64 | else: 65 | form_validated = False 66 | new_object = obj 67 | prefixes = {} 68 | if hasattr(self, 'inline_instances'): 69 | inline_instances = self.inline_instances 70 | 71 | else: 72 | inline_instances = [] 73 | 74 | zipped_formsets = zip(self.get_formsets(request, new_object), 75 | inline_instances) 76 | for FormSet, inline in zipped_formsets: 77 | prefix = FormSet.get_default_prefix() 78 | prefixes[prefix] = prefixes.get(prefix, 0) + 1 79 | if prefixes[prefix] != 1: 80 | prefix = "%s-%s" % (prefix, prefixes[prefix]) 81 | formset = FormSet(request.POST, request.FILES, 82 | instance=new_object, prefix=prefix, 83 | queryset=inline.queryset(request)) 84 | formsets.append(formset) 85 | 86 | if all_valid(formsets) and form_validated: 87 | self.save_model(request, new_object, form, change=True) 88 | form.save_m2m() 89 | for formset in formsets: 90 | self.save_formset(request, form, formset, change=True) 91 | 92 | change_message = self.construct_change_message( 93 | request, form, formsets) 94 | self.log_change(request, new_object, change_message) 95 | 96 | return HttpResponseRedirect('push/') 97 | 98 | context = {}.update(extra_context) 99 | return self.render_change_form(request, context, change=True, obj=obj) 100 | 101 | @csrf_protect_m 102 | @transaction.commit_on_success 103 | def pushing_view(self, request, object_id, form_url='', extra_context=None): 104 | batch_push_item_pk = request.POST.get('push-batch-item') 105 | if request.is_ajax() and batch_push_item_pk: 106 | batch_push_item = BatchPushItem.objects.get(pk=batch_push_item_pk) 107 | if not batch_push_item.batch.first_push_attempt: 108 | batch_push_item.batch.first_push_attempt = datetime.now() 109 | batch_push_item.batch.save() 110 | client.push_one(batch_push_item) 111 | return render_to_response('admin/nudge/batch/_batch_item_row.html', 112 | {'batch_item': batch_push_item}) 113 | 114 | batch = self.model.objects.get(pk=object_id) 115 | 116 | if request.method == 'POST' and 'abort_preflight' in request.POST: 117 | BatchPushItem.objects.filter(batch=batch).delete() 118 | batch.preflight = None 119 | batch.save() 120 | 121 | if request.method == 'POST' and 'push_now' in request.POST: 122 | client.push_batch(batch) 123 | 124 | if not batch.preflight: 125 | return HttpResponseRedirect('../') 126 | 127 | batch_push_items = BatchPushItem.objects.filter(batch=batch) 128 | context = {'batch_push_items': batch_push_items, 129 | 'media': mark_safe(self.media)} 130 | 131 | opts = self.model._meta 132 | app_label = opts.app_label 133 | return TemplateResponse(request, [ 134 | "admin/%s/%s/push.html" % (app_label, opts.object_name.lower()), 135 | ], context, current_app=self.admin_site.name) 136 | 137 | def get_urls(self): 138 | urlpatterns = super(BatchAdmin, self).get_urls() 139 | 140 | def wrap(view): 141 | def wrapper(*args, **kwargs): 142 | return self.admin_site.admin_view(view)(*args, **kwargs) 143 | 144 | return update_wrapper(wrapper, view) 145 | 146 | info = self.model._meta.app_label, self.model._meta.module_name 147 | 148 | urlpatterns = patterns('', url(r'^(.+)/push/$', 149 | wrap(self.pushing_view), 150 | name='%s_%s_push' % info)) + urlpatterns 151 | return urlpatterns 152 | 153 | def save_model(self, request, obj, form, change): 154 | items_str = request.POST.getlist('changes_in_batch') 155 | 156 | obj.selected_items_packed = json.dumps(items_str) 157 | if '_save_and_push' in request.POST: 158 | obj.preflight = datetime.now() 159 | obj.save() 160 | for selected_item in obj.selected_items: 161 | bi = utils.inflate_batch_item(selected_item, obj) 162 | bi.save() 163 | request.POST['_continue'] = 1 164 | obj.save() 165 | 166 | 167 | admin.site.register(Batch, BatchAdmin) 168 | -------------------------------------------------------------------------------- /src/nudge/client.py: -------------------------------------------------------------------------------- 1 | """ 2 | Commands to send to a nudge server 3 | """ 4 | import datetime 5 | import hashlib 6 | import os 7 | import pickle 8 | import urllib 9 | import urllib2 10 | import json 11 | from Crypto.Cipher import AES 12 | 13 | from django.conf import settings 14 | from django.core import serializers 15 | from django.db import models 16 | from django.contrib.contenttypes.models import ContentType 17 | 18 | from nudge.models import Batch, BatchPushItem 19 | from itertools import chain 20 | from reversion import get_for_object 21 | from urlparse import urljoin 22 | 23 | from .exceptions import CommandException 24 | 25 | from django.conf import settings 26 | 27 | IGNORE_RELATIONSHIPS = [] 28 | 29 | if hasattr(settings, 'NUDGE_IGNORE_RELATIONSHIPS'): 30 | for model_reference in settings.NUDGE_IGNORE_RELATIONSHIPS: 31 | app_label, model_label = model_reference.split('.') 32 | ct = ContentType.objects.get_by_natural_key(app_label, model_label) 33 | IGNORE_RELATIONSHIPS.append(ct.model_class()) 34 | 35 | def encrypt(key, plaintext): 36 | m = hashlib.md5(os.urandom(16)) 37 | iv = m.digest() 38 | encobj = AES.new(key, AES.MODE_CBC, iv) 39 | pad = lambda s: s + (16 - len(s) % 16) * ' ' 40 | return (encobj.encrypt(pad(plaintext)).encode('hex'), iv) 41 | 42 | 43 | def encrypt_batch(key, b_plaintext): 44 | """Encrypts a pickled batch for sending to server""" 45 | encrypted, iv = encrypt(key, b_plaintext) 46 | return {'batch': encrypted, 'iv': iv.encode('hex')} 47 | 48 | 49 | def serialize_objects(key, batch_push_items): 50 | """ 51 | Returns an urlencoded pickled serialization of a batch ready to be sent 52 | to a nudge server. 53 | """ 54 | batch_objects = [] 55 | dependencies = [] 56 | deletions = [] 57 | 58 | for batch_item in batch_push_items: 59 | version = batch_item.version 60 | if version.type < 2 and version.object: 61 | updated_obj=version.object 62 | batch_objects.append(updated_obj) 63 | options = updated_obj._meta 64 | fk_fields = [f for f in options.fields if 65 | isinstance(f, models.ForeignKey)] 66 | m2m_fields = [field.name for field in options.many_to_many] 67 | through_fields = [rel.get_accessor_name() for rel in 68 | options.get_all_related_objects()] 69 | for related_obj in [getattr(updated_obj, f.name) for f in fk_fields]: 70 | if related_obj and related_obj not in dependencies and type(related_obj) not in IGNORE_RELATIONSHIPS: 71 | dependencies.append(related_obj) 72 | 73 | for manager_name in chain(m2m_fields, through_fields): 74 | manager = getattr(updated_obj, manager_name) 75 | for related_obj in manager.all(): 76 | if related_obj and related_obj not in dependencies and type(related_obj) not in IGNORE_RELATIONSHIPS: 77 | dependencies.append(related_obj) 78 | 79 | else: 80 | app_label = batch_item.version.content_type.app_label 81 | model_label = batch_item.version.content_type.model 82 | object_id = batch_item.version.object_id 83 | deletions.append((app_label, model_label, object_id)) 84 | 85 | batch_items_serialized = serializers.serialize('json', batch_objects) 86 | dependencies_serialized = serializers.serialize('json', dependencies) 87 | b_plaintext = pickle.dumps({'update': batch_items_serialized, 88 | 'deletions' : json.dumps(deletions), 89 | 'dependencies': dependencies_serialized}) 90 | 91 | return encrypt_batch(key, b_plaintext) 92 | 93 | 94 | def send_command(target, data): 95 | """sends a nudge api command""" 96 | url = urljoin(settings.NUDGE_REMOTE_ADDRESS, target) 97 | req = urllib2.Request(url, urllib.urlencode(data)) 98 | try: 99 | return urllib2.urlopen(req) 100 | except urllib2.HTTPError, e: 101 | raise CommandException( 102 | 'An exception occurred while contacting %s: %s' % 103 | (url, e), e) 104 | 105 | 106 | def push_one(batch_push_item): 107 | key = settings.NUDGE_KEY.decode('hex') 108 | if batch_push_item.last_tried and batch_push_item.success: 109 | return 200 110 | batch_push_item.last_tried = datetime.datetime.now() 111 | try: 112 | response = send_command( 113 | 'batch/', serialize_objects(key, [batch_push_item])) 114 | if response.getcode() == 200: 115 | batch_push_item.success=True 116 | except CommandException, e: 117 | response = e.orig_exception 118 | batch_push_item.save() 119 | return response.getcode() 120 | 121 | 122 | def push_batch(batch): 123 | """ 124 | Pushes a batch to a server, logs push and timestamps on success 125 | """ 126 | batch_push_items = BatchPushItem.objects.filter(batch=batch) 127 | if not batch.first_push_attempt: 128 | batch.first_push_attempt = datetime.datetime.now() 129 | for batch_push_item in batch_push_items: 130 | push_one(batch_push_item) 131 | batch.save() 132 | 133 | 134 | def push_test_batch(): 135 | """ 136 | pushes empty batch to server to test settings and returns True on success 137 | """ 138 | try: 139 | key = settings.NUDGE_KEY.decode('hex') 140 | response = send_command('batch/', serialize_batch(key, Batch())) 141 | return False if response.getcode() != 200 else True 142 | except: 143 | return False 144 | -------------------------------------------------------------------------------- /src/nudge/demo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cfpb/django-nudge/16fbb7ec75620582b486c7a4f6df551083cddcfa/src/nudge/demo/__init__.py -------------------------------------------------------------------------------- /src/nudge/demo/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from models import Post 3 | 4 | 5 | admin.site.register(Post) 6 | -------------------------------------------------------------------------------- /src/nudge/demo/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Author(models.Model): 5 | name = models.CharField(max_length=1000) 6 | 7 | 8 | class Post(models.Model): 9 | title = models.CharField(max_length=1000) 10 | author = models.ForeignKey(Author) 11 | body = models.TextField() 12 | 13 | def __unicode__(self): 14 | return self.title 15 | -------------------------------------------------------------------------------- /src/nudge/demo/tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file demonstrates writing tests using the unittest module. These will pass 3 | when you run "manage.py test". 4 | 5 | Replace this with more appropriate tests for your application. 6 | """ 7 | 8 | from django.test import TestCase 9 | 10 | 11 | class SimpleTest(TestCase): 12 | def test_basic_addition(self): 13 | """ 14 | Tests that 1 + 1 always equals 2. 15 | """ 16 | self.assertEqual(1 + 1, 2) 17 | -------------------------------------------------------------------------------- /src/nudge/demo/views.py: -------------------------------------------------------------------------------- 1 | # Create your views here. 2 | -------------------------------------------------------------------------------- /src/nudge/exceptions.py: -------------------------------------------------------------------------------- 1 | class BaseNudgeException(Exception): 2 | """Base class for all Nudge exceptions. Should never be raised directly""" 3 | pass 4 | 5 | 6 | class CommandException(BaseNudgeException): 7 | """An exception occurred while trying to perform a remote Nudge command""" 8 | 9 | def __init__(self, msg, orig_exception): 10 | self.orig_exception = orig_exception 11 | self.msg = msg 12 | 13 | 14 | class BatchValidationError(BaseNudgeException): 15 | "This batch contains an error" 16 | 17 | def __init__(self, batch): 18 | self.batch = batch 19 | self.msg = "Batch Validation Failed" 20 | 21 | 22 | class BatchPushFailure(BaseNudgeException): 23 | "Pushing this batch failed" 24 | 25 | def __init__(self, http_status=500): 26 | self.http_status=http_status 27 | -------------------------------------------------------------------------------- /src/nudge/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cfpb/django-nudge/16fbb7ec75620582b486c7a4f6df551083cddcfa/src/nudge/management/__init__.py -------------------------------------------------------------------------------- /src/nudge/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cfpb/django-nudge/16fbb7ec75620582b486c7a4f6df551083cddcfa/src/nudge/management/commands/__init__.py -------------------------------------------------------------------------------- /src/nudge/management/commands/new_nudge_key.py: -------------------------------------------------------------------------------- 1 | """ 2 | (Re)generates a new local_key 3 | """ 4 | from django.core.management.base import NoArgsCommand 5 | from nudge.utils import generate_key 6 | 7 | 8 | class Command(NoArgsCommand): 9 | def handle_noargs(self, **options): 10 | new_key = generate_key() 11 | print "# add this to your settings.py" 12 | print "NUDGE_KEY = '%s'" % new_key 13 | -------------------------------------------------------------------------------- /src/nudge/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: 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 | 8 | class Migration(SchemaMigration): 9 | depends_on = ( 10 | ('reversion', '0001_initial'), 11 | ) 12 | 13 | def forwards(self, orm): 14 | # Adding model 'BatchPushItem' 15 | db.create_table('nudge_batchpushitem', ( 16 | ('id', 17 | self.gf('django.db.models.fields.AutoField')(primary_key=True)), 18 | ('batch', self.gf('django.db.models.fields.related.ForeignKey')( 19 | to=orm['nudge.Batch'])), 20 | ('version', self.gf('django.db.models.fields.related.ForeignKey')( 21 | to=orm['reversion.Version'])), 22 | ('last_tried', 23 | self.gf('django.db.models.fields.DateTimeField')(null=True)), 24 | ('success', 25 | self.gf('django.db.models.fields.BooleanField')(default=False)), 26 | )) 27 | db.send_create_signal('nudge', ['BatchPushItem']) 28 | 29 | # Adding model 'Batch' 30 | db.create_table('nudge_batch', ( 31 | ('id', 32 | self.gf('django.db.models.fields.AutoField')(primary_key=True)), 33 | ('title', 34 | self.gf('django.db.models.fields.CharField')(max_length=1000)), 35 | ('description', 36 | self.gf('django.db.models.fields.TextField')(blank=True)), 37 | ('start_date', self.gf('django.db.models.fields.DateField')( 38 | default=datetime.datetime(2012, 9, 13, 0, 0))), 39 | ('created', 40 | self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, 41 | blank=True)), 42 | ('updated', 43 | self.gf('django.db.models.fields.DateTimeField')(auto_now=True, 44 | blank=True)), 45 | ('preflight', 46 | self.gf('django.db.models.fields.DateTimeField')(null=True, 47 | blank=True)), 48 | ('first_push_attempt', 49 | self.gf('django.db.models.fields.DateTimeField')(null=True, 50 | blank=True)), 51 | ('selected_items_packed', 52 | self.gf('django.db.models.fields.TextField')(default='[]')), 53 | )) 54 | db.send_create_signal('nudge', ['Batch']) 55 | 56 | # Adding model 'PushHistoryItem' 57 | db.create_table('nudge_pushhistoryitem', ( 58 | ('id', 59 | self.gf('django.db.models.fields.AutoField')(primary_key=True)), 60 | ('batch', self.gf('django.db.models.fields.related.ForeignKey')( 61 | to=orm['nudge.Batch'], on_delete=models.PROTECT)), 62 | ('created', 63 | self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, 64 | blank=True)), 65 | ('http_result', 66 | self.gf('django.db.models.fields.IntegerField')(null=True, 67 | blank=True)), 68 | )) 69 | db.send_create_signal('nudge', ['PushHistoryItem']) 70 | 71 | # Adding model 'BatchItem' 72 | db.create_table('nudge_batchitem', ( 73 | ('id', 74 | self.gf('django.db.models.fields.AutoField')(primary_key=True)), 75 | ('object_id', self.gf('django.db.models.fields.IntegerField')()), 76 | ('version', self.gf('django.db.models.fields.related.ForeignKey')( 77 | to=orm['reversion.Version'])), 78 | ('batch', self.gf('django.db.models.fields.related.ForeignKey')( 79 | to=orm['nudge.Batch'])), 80 | )) 81 | db.send_create_signal('nudge', ['BatchItem']) 82 | 83 | 84 | def backwards(self, orm): 85 | # Deleting model 'BatchPushItem' 86 | db.delete_table('nudge_batchpushitem') 87 | 88 | # Deleting model 'Batch' 89 | db.delete_table('nudge_batch') 90 | 91 | # Deleting model 'PushHistoryItem' 92 | db.delete_table('nudge_pushhistoryitem') 93 | 94 | # Deleting model 'BatchItem' 95 | db.delete_table('nudge_batchitem') 96 | 97 | 98 | models = { 99 | 'auth.group': { 100 | 'Meta': {'object_name': 'Group'}, 101 | 'id': ( 102 | 'django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 103 | 'name': ('django.db.models.fields.CharField', [], 104 | {'unique': 'True', 'max_length': '80'}), 105 | 'permissions': ( 106 | 'django.db.models.fields.related.ManyToManyField', [], 107 | {'to': "orm['auth.Permission']", 'symmetrical': 'False', 108 | 'blank': 'True'}) 109 | }, 110 | 'auth.permission': { 111 | 'Meta': { 112 | 'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 113 | 'unique_together': "(('content_type', 'codename'),)", 114 | 'object_name': 'Permission'}, 115 | 'codename': ( 116 | 'django.db.models.fields.CharField', [], {'max_length': '100'}), 117 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], 118 | {'to': "orm['contenttypes.ContentType']"}), 119 | 'id': ( 120 | 'django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 121 | 'name': ( 122 | 'django.db.models.fields.CharField', [], {'max_length': '50'}) 123 | }, 124 | 'auth.user': { 125 | 'Meta': {'object_name': 'User'}, 126 | 'date_joined': ('django.db.models.fields.DateTimeField', [], 127 | {'default': 'datetime.datetime.now'}), 128 | 'email': ('django.db.models.fields.EmailField', [], 129 | {'max_length': '75', 'blank': 'True'}), 130 | 'first_name': ('django.db.models.fields.CharField', [], 131 | {'max_length': '30', 'blank': 'True'}), 132 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], 133 | {'to': "orm['auth.Group']", 'symmetrical': 'False', 134 | 'blank': 'True'}), 135 | 'id': ( 136 | 'django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 137 | 'is_active': ( 138 | 'django.db.models.fields.BooleanField', [], {'default': 'True'}), 139 | 'is_staff': ( 140 | 'django.db.models.fields.BooleanField', [], {'default': 'False'}), 141 | 'is_superuser': ( 142 | 'django.db.models.fields.BooleanField', [], {'default': 'False'}), 143 | 'last_login': ('django.db.models.fields.DateTimeField', [], 144 | {'default': 'datetime.datetime.now'}), 145 | 'last_name': ('django.db.models.fields.CharField', [], 146 | {'max_length': '30', 'blank': 'True'}), 147 | 'password': ( 148 | 'django.db.models.fields.CharField', [], {'max_length': '128'}), 149 | 'user_permissions': ( 150 | 'django.db.models.fields.related.ManyToManyField', [], 151 | {'to': "orm['auth.Permission']", 'symmetrical': 'False', 152 | 'blank': 'True'}), 153 | 'username': ('django.db.models.fields.CharField', [], 154 | {'unique': 'True', 'max_length': '30'}) 155 | }, 156 | 'contenttypes.contenttype': { 157 | 'Meta': {'ordering': "('name',)", 158 | 'unique_together': "(('app_label', 'model'),)", 159 | 'object_name': 'ContentType', 160 | 'db_table': "'django_content_type'"}, 161 | 'app_label': ( 162 | 'django.db.models.fields.CharField', [], {'max_length': '100'}), 163 | 'id': ( 164 | 'django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 165 | 'model': ( 166 | 'django.db.models.fields.CharField', [], {'max_length': '100'}), 167 | 'name': ( 168 | 'django.db.models.fields.CharField', [], {'max_length': '100'}) 169 | }, 170 | 'nudge.batch': { 171 | 'Meta': {'object_name': 'Batch'}, 172 | 'created': ('django.db.models.fields.DateTimeField', [], 173 | {'auto_now_add': 'True', 'blank': 'True'}), 174 | 'description': ( 175 | 'django.db.models.fields.TextField', [], {'blank': 'True'}), 176 | 'first_push_attempt': ('django.db.models.fields.DateTimeField', [], 177 | {'null': 'True', 'blank': 'True'}), 178 | 'id': ( 179 | 'django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 180 | 'preflight': ('django.db.models.fields.DateTimeField', [], 181 | {'null': 'True', 'blank': 'True'}), 182 | 'selected_items_packed': ( 183 | 'django.db.models.fields.TextField', [], {'default': "'[]'"}), 184 | 'start_date': ('django.db.models.fields.DateField', [], 185 | {'default': 'datetime.datetime(2012, 9, 13, 0, 0)'}), 186 | 'title': ( 187 | 'django.db.models.fields.CharField', [], {'max_length': '1000'}), 188 | 'updated': ('django.db.models.fields.DateTimeField', [], 189 | {'auto_now': 'True', 'blank': 'True'}) 190 | }, 191 | 'nudge.batchitem': { 192 | 'Meta': {'object_name': 'BatchItem'}, 193 | 'batch': ('django.db.models.fields.related.ForeignKey', [], 194 | {'to': "orm['nudge.Batch']"}), 195 | 'id': ( 196 | 'django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 197 | 'object_id': ('django.db.models.fields.IntegerField', [], {}), 198 | 'version': ('django.db.models.fields.related.ForeignKey', [], 199 | {'to': "orm['reversion.Version']"}) 200 | }, 201 | 'nudge.batchpushitem': { 202 | 'Meta': {'object_name': 'BatchPushItem'}, 203 | 'batch': ('django.db.models.fields.related.ForeignKey', [], 204 | {'to': "orm['nudge.Batch']"}), 205 | 'id': ( 206 | 'django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 207 | 'last_tried': ( 208 | 'django.db.models.fields.DateTimeField', [], {'null': 'True'}), 209 | 'success': ( 210 | 'django.db.models.fields.BooleanField', [], {'default': 'False'}), 211 | 'version': ('django.db.models.fields.related.ForeignKey', [], 212 | {'to': "orm['reversion.Version']"}) 213 | }, 214 | 'nudge.pushhistoryitem': { 215 | 'Meta': {'object_name': 'PushHistoryItem'}, 216 | 'batch': ('django.db.models.fields.related.ForeignKey', [], 217 | {'to': "orm['nudge.Batch']", 218 | 'on_delete': 'models.PROTECT'}), 219 | 'created': ('django.db.models.fields.DateTimeField', [], 220 | {'auto_now_add': 'True', 'blank': 'True'}), 221 | 'http_result': ('django.db.models.fields.IntegerField', [], 222 | {'null': 'True', 'blank': 'True'}), 223 | 'id': ( 224 | 'django.db.models.fields.AutoField', [], {'primary_key': 'True'}) 225 | }, 226 | 'reversion.revision': { 227 | 'Meta': {'object_name': 'Revision'}, 228 | 'comment': ( 229 | 'django.db.models.fields.TextField', [], {'blank': 'True'}), 230 | 'date_created': ('django.db.models.fields.DateTimeField', [], 231 | {'auto_now_add': 'True', 'blank': 'True'}), 232 | 'id': ( 233 | 'django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 234 | 'manager_slug': ('django.db.models.fields.CharField', [], 235 | {'default': "'default'", 'max_length': '200', 236 | 'db_index': 'True'}), 237 | 'user': ('django.db.models.fields.related.ForeignKey', [], 238 | {'to': "orm['auth.User']", 'null': 'True', 239 | 'blank': 'True'}) 240 | }, 241 | 'reversion.version': { 242 | 'Meta': {'object_name': 'Version'}, 243 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], 244 | {'to': "orm['contenttypes.ContentType']"}), 245 | 'format': ( 246 | 'django.db.models.fields.CharField', [], {'max_length': '255'}), 247 | 'id': ( 248 | 'django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 249 | 'object_id': ('django.db.models.fields.TextField', [], {}), 250 | 'object_id_int': ('django.db.models.fields.IntegerField', [], 251 | {'db_index': 'True', 'null': 'True', 252 | 'blank': 'True'}), 253 | 'object_repr': ('django.db.models.fields.TextField', [], {}), 254 | 'revision': ('django.db.models.fields.related.ForeignKey', [], 255 | {'to': "orm['reversion.Revision']"}), 256 | 'serialized_data': ('django.db.models.fields.TextField', [], {}), 257 | 'type': ('django.db.models.fields.PositiveSmallIntegerField', [], 258 | {'db_index': 'True'}) 259 | } 260 | } 261 | 262 | complete_apps = ['nudge'] 263 | -------------------------------------------------------------------------------- /src/nudge/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cfpb/django-nudge/16fbb7ec75620582b486c7a4f6df551083cddcfa/src/nudge/migrations/__init__.py -------------------------------------------------------------------------------- /src/nudge/models.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from django.db import models 3 | 4 | try: 5 | import simplejson as json 6 | except ImportError: 7 | import json 8 | 9 | 10 | class BatchPushItem(models.Model): 11 | batch = models.ForeignKey('Batch') 12 | version = models.ForeignKey('reversion.Version') 13 | last_tried = models.DateTimeField(null=True) 14 | success = models.BooleanField(default=False) 15 | 16 | def __unicode__(self): 17 | return unicode(self.version) 18 | 19 | def version_type_string(self): 20 | return VERSION_TYPE_LOOKUP[self.version.type] 21 | 22 | 23 | def default_batch_start_date(): 24 | # date last completed batch pushed 25 | # or date of earliest revision 26 | # or today 27 | return date.today() 28 | 29 | 30 | class Batch(models.Model): 31 | title = models.CharField(max_length=1000) 32 | description = models.TextField(blank=True) 33 | start_date = models.DateField(default=default_batch_start_date) 34 | created = models.DateTimeField(auto_now_add=True) 35 | updated = models.DateTimeField(auto_now=True) 36 | preflight = models.DateTimeField(null=True, blank=True) 37 | first_push_attempt = models.DateTimeField(null=True, blank=True) 38 | selected_items_packed = models.TextField(default=json.dumps([])) 39 | 40 | def __unicode__(self): 41 | return u'%s' % self.title 42 | 43 | def is_valid(self, test_only=True): 44 | return True 45 | 46 | @property 47 | def selected_items(self): 48 | if not hasattr(self, '_selected_items'): 49 | self._selected_items = json.loads(self.selected_items_packed) 50 | return self._selected_items 51 | 52 | class Meta: 53 | verbose_name_plural = 'batches' 54 | permissions = ( 55 | ('push_batch', 'Can push batches'), 56 | ) 57 | 58 | 59 | class PushHistoryItem(models.Model): 60 | batch = models.ForeignKey(Batch, on_delete=models.PROTECT) 61 | created = models.DateTimeField(auto_now_add=True) 62 | http_result = models.IntegerField(blank=True, null=True) 63 | 64 | 65 | class BatchItem(models.Model): 66 | object_id = models.IntegerField() 67 | version = models.ForeignKey('reversion.Version') 68 | batch = models.ForeignKey(Batch) 69 | 70 | def __unicode__(self): 71 | return u'%s' % self.version.object_repr 72 | 73 | 74 | from nudge.utils import VERSION_TYPE_LOOKUP 75 | -------------------------------------------------------------------------------- /src/nudge/server.py: -------------------------------------------------------------------------------- 1 | """ 2 | Handles commands received from a Nudge client 3 | """ 4 | import binascii 5 | import pickle 6 | from Crypto.Cipher import AES 7 | from django.contrib.contenttypes.models import ContentType 8 | from django.core import serializers 9 | import reversion 10 | from reversion.models import Version 11 | 12 | 13 | try: 14 | import simplejson as json 15 | except ImportError: 16 | import json 17 | 18 | 19 | 20 | 21 | def decrypt(key, ciphertext, iv): 22 | """Decrypts message sent from client using shared symmetric key""" 23 | ciphertext = binascii.unhexlify(ciphertext) 24 | decobj = AES.new(key, AES.MODE_CBC, iv) 25 | plaintext = decobj.decrypt(ciphertext) 26 | return plaintext 27 | 28 | 29 | def versions(keys): 30 | results = {} 31 | for key in keys: 32 | app, model, pk = key.split('~') 33 | content_type = ContentType.objects.get_by_natural_key(app, model) 34 | versions = Version.objects.all().filter( 35 | content_type=content_type 36 | ).filter(object_id=pk).order_by('-revision__date_created') 37 | if versions: 38 | latest = versions[0] 39 | results[key] = (latest.pk, 40 | latest.type, 41 | latest.revision 42 | .date_created.strftime('%b %d, %Y, %I:%M %p')) 43 | else: 44 | results[key] = None 45 | return json.dumps(results) 46 | 47 | 48 | def process_batch(key, batch_info, iv): 49 | """Loops through items in a batch and processes them.""" 50 | batch_info = pickle.loads(decrypt(key, batch_info, iv.decode('hex'))) 51 | success = True 52 | 53 | 54 | if 'dependencies' in batch_info: 55 | dependencies = serializers.deserialize('json', batch_info['dependencies']) 56 | for dep in dependencies: 57 | dep.save() 58 | 59 | if 'update' in batch_info: 60 | updates = serializers.deserialize('json', batch_info['update']) 61 | for item in updates: 62 | with reversion.create_revision(): 63 | item.save() 64 | 65 | if 'deletions' in batch_info: 66 | deletions = json.loads(batch_info['deletions']) 67 | for deletion in deletions: 68 | app_label, model_label, object_id = deletion 69 | ct = ContentType.objects.get_by_natural_key(app_label, model_label) 70 | for result in ct.model_class().objects.filter(pk=object_id): 71 | with reversion.create_revision(): 72 | result.delete() 73 | 74 | return success 75 | -------------------------------------------------------------------------------- /src/nudge/templates/500.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cfpb/django-nudge/16fbb7ec75620582b486c7a4f6df551083cddcfa/src/nudge/templates/500.html -------------------------------------------------------------------------------- /src/nudge/templates/admin/nudge/batch/_batch_item_row.html: -------------------------------------------------------------------------------- 1 | 3 | {{ batch_item }} 4 | {{ batch_item.version_type_string }} 5 | {{ batch_item.version.revision.date_created }} 6 | {% if not batch_item.last_tried %} Ready to push {% else %} 7 | {% if batch_item.success %} Pushed at {% else %} push failed at 8 | {% endif %} 9 | {{ batch_item.last_tried }} 10 | {% endif %} 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/nudge/templates/admin/nudge/batch/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n admin_modify nudge_admin_helpers version_display %} 3 | {% load url from future %} 4 | 5 | {% block extrahead %}{{ block.super }} 6 | {% url 'admin:jsi18n' as jsi18nurl %} 7 | 9 | {{ media }} 10 | {% endblock %} 11 | 12 | {% block extrastyle %}{{ block.super }} 13 | {% endblock %} 15 | 16 | {% block coltype %}{% if ordered_objects %}colMS{% else %}colM 17 | {% endif %}{% endblock %} 18 | 19 | {% block bodyclass %}{{ opts.app_label }}-{{ opts.object_name.lower }} 20 | change-form{% endblock %} 21 | 22 | {% block breadcrumbs %}{% if not is_popup %} 23 | 32 | {% endif %}{% endblock %} 33 | 34 | {% block content %} 35 |
36 | {% block object-tools %} 37 | {% if change %}{% if not is_popup %} 38 | 49 | {% endif %}{% endif %} 50 | {% endblock %} 51 | 52 | 53 | 54 |
56 | {% csrf_token %}{% block form_top %}{% endblock %} 57 |
58 | {% if is_popup %} 59 | {% endif %} 60 | {% if save_on_top %}{% submit_row %}{% endif %} 61 | {% if errors %} 62 |

63 | {% blocktrans count errors|length as counter %}Please 64 | correct the error below.{% plural %}Please correct the 65 | errors below.{% endblocktrans %} 66 |

67 | {{ adminform.form.non_field_errors }} 68 | {% endif %} 69 | 70 | {% for fieldset in adminform %} 71 | {% include "admin/includes/fieldset.html" %} 72 | {% endfor %} 73 | 74 | {% block after_field_sets %}{% endblock %} 75 | 76 | {% for inline_admin_formset in inline_admin_formsets %} 77 | {% include inline_admin_formset.opts.template %} 78 | {% endfor %} 79 | 80 | {% block after_related_objects %}{% endblock %} 81 | 82 | 83 | {% if versions_selected %} 84 |

Changes attached to this batch

85 | 86 | 87 | 88 | 89 | 90 | {% for version in versions_selected %} 91 | 92 | 96 | 99 | 100 | 101 | {% endfor %} 102 |
versionRemove?
93 | [{{ version.content_type.name }}] {{ version.object_repr }} 94 | ({{ version.type|change_type }}) 95 |
103 | {% endif %} 104 | 105 | 106 | {% if available_changes %} 107 |

Available Changes

108 | 109 | {% regroup available_changes by content_type as changes_by_ct %} 110 | 111 | {% for ct in changes_by_ct %} 112 | 113 |

{{ ct.grouper.name }} ({{ ct.grouper.app_label }})

114 | 115 | 116 | 117 | 119 | 120 | 121 | 122 | 123 | {% for change in ct.list %} 124 | 125 | 126 | 131 | 132 | 133 | 134 | 139 | 140 | {% endfor %} 141 | 142 |
118 | Item NameRevision TypeDateRemote updated
130 | {{ change }} {{ change.version_type_string }} {{ change.version.revision.date_created }}{% if change.remote_timestamp %} 135 | {{ change.remote_timestamp }} ( 136 | {{ change.remote_change_type }}) 137 | {% else %} New {% endif %} 138 |
143 | 144 | {% endfor %} 145 | 146 | 147 | 148 | 149 | 150 | {% endif %} 151 | {% submit_batch_row %} 152 | {% for history_item in history %} 153 | 154 | {{ history.item }} 155 | 156 | {% endfor %} 157 | 158 | {% if adminform and add %} 159 | 160 | {% endif %} 161 | 162 | {# JavaScript for prepopulated fields #} 163 | {% prepopulated_fields_js %} 164 | 165 |
166 |
167 | 168 | 179 | 180 | 181 | {% endblock %} 182 | -------------------------------------------------------------------------------- /src/nudge/templates/admin/nudge/batch/push.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n admin_modify nudge_admin_helpers version_display %} 3 | {% load url from future %} 4 | 5 | {% block extrahead %}{{ block.super }} 6 | {% url 'admin:jsi18n' as jsi18nurl %} 7 | 9 | {{ media }} 10 | {% endblock %} 11 | 12 | {% block extrastyle %}{{ block.super }} 13 | {% endblock %} 15 | 16 | {% block coltype %}{% if ordered_objects %}colMS{% else %}colM 17 | {% endif %}{% endblock %} 18 | 19 | {% block bodyclass %}{{ opts.app_label }}-{{ opts.object_name.lower }} 20 | change-form{% endblock %} 21 | 22 | {% block breadcrumbs %}{% if not is_popup %} 23 | 29 | {% endif %}{% endblock %} 30 | 31 | {% block content %} 32 |
33 | {% block object-tools %} 34 | {% if change %}{% if not is_popup %} 35 | 46 | {% endif %}{% endif %} 47 | 48 | 49 | 50 |

Batch Contents

51 | 52 | {% if batch_push_items %} 53 | {% regroup batch_push_items by version.content_type as ct_list %} 54 | 55 | {% for ct in ct_list %} 56 | 57 |

{{ ct.grouper.name }} ({{ ct.grouper.app_label }})

58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | {% for batch_item in ct.list %} 66 | 67 | {% include "admin/nudge/batch/_batch_item_row.html" %} 68 | 69 | {% endfor %} 70 |
Item NameRevision TypeDatestatus
71 | {% endfor %} 72 |
73 |
74 | 75 | 76 | {% csrf_token %} 77 |
78 |
79 | {% else %} 80 | 81 |

No changes in this batch!

82 | 83 | {% endif %} 84 | {% endblock %} 85 | 86 | 87 | 88 | 89 |
90 | 91 | 92 | 94 | 95 | 96 | {% endblock %} 97 | -------------------------------------------------------------------------------- /src/nudge/templates/admin/nudge/batch/read_only.html: -------------------------------------------------------------------------------- 1 | The batch "{{ object.title }}" already been pushed, and can not be 2 | edited 3 | 4 | 5 | 6 | {% if versions_selected %} 7 |

Changes attached to this batch

8 | 9 | 10 | 11 | 12 | 13 | {% for version in versions_selected %} 14 | 15 | 16 | 17 | 18 | {% endfor %} 19 |
TypeName
{{ version.content_type.name }} {{ version.object_repr }}
20 | {% endif %} 21 | 22 |

Push History

23 | 41 | 42 | -------------------------------------------------------------------------------- /src/nudge/templates/admin/nudge/batch/submit_line.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |
3 | {% if show_save %} 4 | {% endif %} 6 | {% if show_save %}{% endif %} 9 | {% if show_delete_link %}{% endif %} 12 | {% if show_save_as_new %} 13 | {% endif %} 15 | {% if show_save_and_add_another %} 16 | {% endif %} 18 | {% if show_save_and_continue %} 19 | {% endif %} 21 |
22 | -------------------------------------------------------------------------------- /src/nudge/templates/admin/nudge/index.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n %} 3 | 4 | {% block extrastyle %}{{ block.super }} 5 | {% endblock %} 7 | 8 | {% block coltype %}colMS{% endblock %} 9 | 10 | {% block bodyclass %}dashboard{% endblock %} 11 | 12 | {% block breadcrumbs %}{% endblock %} 13 | 14 | {% block content %} 15 |
16 | 17 | {% if app_list %} 18 | {% for app in app_list %} 19 |
20 | 21 | 24 | {% for model in app.models %} 25 | 26 | {% if model.perms.change %} 27 | 30 | {% else %} 31 | 32 | {% endif %} 33 | {% if model.perms.add %} 34 | {% if not model.name == 'Settings' %} 35 | 38 | {% else %} 39 | 40 | {% endif %} 41 | {% endif %} 42 | {% if model.perms.change %} 43 | 46 | {% else %} 47 | 48 | {% endif %} 49 | 50 | {% endfor %} 51 |
22 | {% blocktrans with app.name as name %} 23 | {{ name }}{% endblocktrans %}
{{ model.name }} 29 | {{ model.name }}{% trans 'Add' %} 37 |  {% trans 'Change' %} 45 |  
52 |
53 | {% endfor %} 54 | {% else %} 55 |

{% trans "You don't have permission to edit anything." %}

56 | {% endif %} 57 |
58 | {% endblock %} 59 | 60 | {% block sidebar %} 61 | 92 | {% endblock %} 93 | -------------------------------------------------------------------------------- /src/nudge/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cfpb/django-nudge/16fbb7ec75620582b486c7a4f6df551083cddcfa/src/nudge/templatetags/__init__.py -------------------------------------------------------------------------------- /src/nudge/templatetags/nudge_admin_helpers.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | 6 | @register.inclusion_tag( 7 | 'admin/nudge/batch/submit_line.html', takes_context=True) 8 | def submit_batch_row(context): 9 | """ 10 | Displays the row of buttons for delete and save. 11 | """ 12 | opts = context['opts'] 13 | change = context['change'] 14 | is_popup = context['is_popup'] 15 | save_as = context['save_as'] 16 | return { 17 | 'onclick_attrib': (opts.get_ordered_objects() and change 18 | and 'onclick="submitOrderForm();"' or ''), 19 | 'show_delete_link': (not is_popup and context['has_delete_permission'] 20 | and (change)), 21 | 'show_save_as_new': (not is_popup and change and save_as), 22 | 'show_save_and_add_another': (context['has_add_permission'] 23 | and not is_popup and (not save_as 24 | or context['add'])), 25 | 'show_save_and_continue': (not is_popup 26 | and context['has_change_permission']), 27 | 'is_popup': is_popup, 28 | 'show_save': True 29 | } 30 | -------------------------------------------------------------------------------- /src/nudge/templatetags/version_display.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from nudge.utils import VERSION_TYPE_LOOKUP 3 | 4 | register = template.Library() 5 | 6 | 7 | @register.filter 8 | def change_type(value): 9 | return VERSION_TYPE_LOOKUP[int(value)] 10 | -------------------------------------------------------------------------------- /src/nudge/tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file demonstrates writing tests using the unittest module. These will pass 3 | when you run "manage.py test". 4 | 5 | Replace this with more appropriate tests for your application. 6 | """ 7 | from django.core.exceptions import ObjectDoesNotExist 8 | 9 | import reversion 10 | from django.test import TestCase 11 | from nudge.client import encrypt, serialize_batch 12 | from nudge.demo.models import Post, Author 13 | from nudge.exceptions import BatchValidationError 14 | from nudge.management.commands import nudgeinit 15 | from nudge.models import Batch, BatchItem 16 | from nudge.server import decrypt, process_batch 17 | from nudge.utils import generate_key, add_versions_to_batch, changed_items 18 | 19 | reversion.register(Post) 20 | reversion.register(Author) 21 | 22 | nudgeinit.Command().handle_noargs() 23 | 24 | 25 | @reversion.create_revision() 26 | def create_author(): 27 | new_author = Author(name="Ross") 28 | new_author.save() 29 | return new_author 30 | 31 | 32 | @reversion.create_revision() 33 | def delete_with_reversion(object): 34 | object.delete() 35 | 36 | 37 | class EncryptionTest(TestCase): 38 | def setUp(self): 39 | self.new_author = create_author() 40 | 41 | def tearDown(self): 42 | Author.objects.all().delete() 43 | 44 | def test_encryption(self): 45 | """ 46 | Tests that encryption and decryption are sane 47 | """ 48 | message = u"Hello, Nudge Encryption!" 49 | key = generate_key() 50 | encrypted, iv = encrypt(key.decode('hex'), message) 51 | decrypted = decrypt(key.decode('hex'), encrypted, iv) 52 | self.assertEqual(message, decrypted.strip()) 53 | 54 | 55 | class BatchTest(TestCase): 56 | def setUp(self): 57 | self.key = generate_key() 58 | self.batch = Batch(title="Best Batch Ever") 59 | self.new_author = create_author() 60 | self.batch.save() 61 | 62 | def tearDown(self): 63 | Author.objects.all().delete() 64 | BatchItem.objects.all().delete() 65 | 66 | def test_batch_serialization_and_processing(self): 67 | add_versions_to_batch(self.batch, changed_items()) 68 | serialized = serialize_batch(self.key.decode('hex'), self.batch) 69 | process_batch(self.key.decode('hex'), 70 | serialized['batch'], 71 | serialized['iv']) 72 | 73 | def test_batch_with_deletion(self): 74 | delete_with_reversion(self.new_author) 75 | add_versions_to_batch(self.batch, changed_items()) 76 | serialized = serialize_batch(self.key.decode('hex'), self.batch) 77 | with self.assertRaises(ObjectDoesNotExist): 78 | process_batch(self.key.decode('hex'), 79 | serialized['batch'], 80 | serialized['iv']) 81 | 82 | 83 | class VersionTest(TestCase): 84 | def setUp(self): 85 | self.new_author = create_author() 86 | 87 | def tearDown(self): 88 | Author.objects.all().delete() 89 | 90 | def test_identify_changes(self): 91 | self.assertIn(self.new_author, 92 | [version.object for version in changed_items()]) 93 | 94 | def test_add_changes_to_batch(self): 95 | new_batch = Batch(title="Best Batch Ever") 96 | new_batch.save() 97 | add_versions_to_batch(new_batch, changed_items()) 98 | self.assertIn(self.new_author, 99 | [bi.version.object for bi 100 | in new_batch.batchitem_set.all()]) 101 | 102 | def test_add_deletion_to_batch(self): 103 | delete_with_reversion(self.new_author) 104 | 105 | def test_batch_validation(self): 106 | batch1 = Batch(title="Best Batch Ever") 107 | batch1.save() 108 | batch2 = Batch(title="2nd Best Batch Ever") 109 | batch2.save() 110 | add_versions_to_batch(batch1, changed_items()) 111 | add_versions_to_batch(batch2, changed_items()) 112 | with self.assertRaises(BatchValidationError): 113 | batch1.is_valid(test_only=False) 114 | -------------------------------------------------------------------------------- /src/nudge/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, url 2 | 3 | 4 | urlpatterns = patterns('nudge.views', 5 | url(r'^batch/$', 'batch'), 6 | url(r'^check-versions/$', 'check_versions'),) 7 | -------------------------------------------------------------------------------- /src/nudge/utils.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import os 3 | 4 | from datetime import datetime 5 | 6 | from django.db.models.fields.related import ( 7 | ReverseSingleRelatedObjectDescriptor, SingleRelatedObjectDescriptor, 8 | ForeignRelatedObjectsDescriptor) 9 | from django.conf import settings 10 | from django.contrib.contenttypes.models import ContentType 11 | from nudge.models import Batch, BatchPushItem 12 | from nudge.exceptions import CommandException 13 | from reversion.models import Version, Revision, VERSION_TYPE_CHOICES 14 | from reversion import get_for_object 15 | 16 | try: 17 | import simplejson as json 18 | except ImportError: 19 | import json 20 | 21 | VERSION_TYPE_LOOKUP = dict(VERSION_TYPE_CHOICES) 22 | 23 | 24 | class PotentialBatchItem(object): 25 | def __init__(self, version, batch=None): 26 | self.content_type = version.content_type 27 | self.pk = version.object_id 28 | self.repr = version.object_repr 29 | self.version = version 30 | if batch: 31 | self.selected = (self.key() in batch.selected_items) 32 | 33 | def __eq__(self, other): 34 | return (self.content_type == other.content_type and 35 | self.pk == other.pk) 36 | 37 | def __unicode__(self): 38 | return self.repr 39 | 40 | def key(self): 41 | return '~'.join((self.content_type.app_label, 42 | self.content_type.model, 43 | self.pk)) 44 | 45 | def version_type_string(self): 46 | return VERSION_TYPE_LOOKUP[self.version.type] 47 | 48 | 49 | def inflate_batch_item(key, batch): 50 | app_label, model_label, pk = key.split('~') 51 | content_type = ContentType.objects.get_by_natural_key(app_label, 52 | model_label) 53 | latest_version = Version.objects.filter( 54 | content_type=content_type 55 | ).filter( 56 | object_id=pk).order_by('-revision__date_created')[0] 57 | return BatchPushItem(batch=batch, version=latest_version) 58 | 59 | 60 | def related_objects(obj): 61 | model = type(obj) 62 | relationship_names = [] 63 | related_types = (ReverseSingleRelatedObjectDescriptor, 64 | SingleRelatedObjectDescriptor,) 65 | for attr in dir(model): 66 | if isinstance(getattr(model, attr), related_types): 67 | relationship_names.append(attr) 68 | return [getattr(obj, relname) for relname in relationship_names 69 | if bool(getattr(obj, relname))] 70 | 71 | 72 | def caster(fields, model): 73 | relationship_names = [] 74 | related_types = (ReverseSingleRelatedObjectDescriptor, 75 | SingleRelatedObjectDescriptor, 76 | ForeignRelatedObjectsDescriptor,) 77 | for attr in dir(model): 78 | if isinstance(getattr(model, attr), related_types): 79 | relationship_names.append(attr) 80 | for rel_name in relationship_names: 81 | rel = getattr(model, rel_name) 82 | if rel_name in fields: 83 | fields[rel_name] = (rel.field.related.parent_model 84 | .objects.get(pk=fields[rel_name])) 85 | return fields 86 | 87 | 88 | def changed_items(for_date, batch=None): 89 | """Returns a list of objects that are new or changed and not pushed""" 90 | from nudge.client import send_command 91 | types = [] 92 | for type_key in settings.NUDGE_SELECTIVE: 93 | app, model = type_key.lower().split('.') 94 | try: 95 | types.append(ContentType.objects.get_by_natural_key(app, model)) 96 | except ContentType.DoesNotExist: 97 | raise ValueError( 98 | 'Model listed in NUDGE_SELECTIVE does not exist: %s.%s' % 99 | (app, model)) 100 | 101 | eligible_versions = Version.objects.all().filter( 102 | revision__date_created__gte=for_date, 103 | content_type__in=types 104 | ).order_by('-revision__date_created') 105 | 106 | pot_batch_items = [PotentialBatchItem(version, batch=batch) 107 | for version in eligible_versions] 108 | 109 | seen_pbis = [] 110 | keys = [pbi.key() for pbi in pot_batch_items] 111 | response = send_command('check-versions/', { 112 | 'keys': json.dumps(keys)} 113 | ).read() 114 | try: 115 | remote_versions = json.loads(response) 116 | except ValueError, e: 117 | raise CommandException( 118 | 'Error decoding \'check-versions\' response: %s' % e, e) 119 | 120 | def seen(key): 121 | if key not in seen_pbis: 122 | seen_pbis.append(key) 123 | return True 124 | else: 125 | return False 126 | 127 | pot_batch_items = filter(seen, pot_batch_items) 128 | screened_pbis = [] 129 | for pbi in pot_batch_items: 130 | remote_details = remote_versions[pbi.key()] 131 | if remote_details: 132 | version_pk, version_type, timestamp = remote_details 133 | remote_dt = datetime.strptime(timestamp,'%b %d, %Y, %I:%M %p') 134 | if remote_dt < pbi.version.revision.date_created.replace(second=0): 135 | pbi.remote_timestamp = timestamp 136 | pbi.remote_change_type = VERSION_TYPE_LOOKUP[version_type] 137 | screened_pbis.append(pbi) 138 | else: 139 | screened_pbis.append(pbi) 140 | 141 | return sorted(screened_pbis, key=lambda pbi: pbi.content_type) 142 | 143 | 144 | def add_versions_to_batch(batch, versions): 145 | """Takes a list of Version objects, and adds them to the given Batch""" 146 | for v in versions: 147 | item = BatchItem(version=v, batch=batch) 148 | item.save() 149 | 150 | 151 | def collect_eligibles(batch): 152 | """Collects all changed items and adds them to supplied batch""" 153 | for e in changed_items(): 154 | e.batch = batch 155 | e.save() 156 | 157 | 158 | def convert_keys_to_string(dictionary): 159 | """ 160 | Recursively converts dictionary keys to strings. 161 | Found at http://stackoverflow.com/a/7027514/104365 162 | """ 163 | if not isinstance(dictionary, dict): 164 | return dictionary 165 | return dict([(str(k), convert_keys_to_string(v)) 166 | for k, v in dictionary.items()]) 167 | 168 | 169 | def generate_key(): 170 | """Generate 32 byte key and return hex representation""" 171 | seed = os.urandom(32) 172 | key = hashlib.sha256(seed).digest().encode('hex') 173 | return key 174 | -------------------------------------------------------------------------------- /src/nudge/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | from django.conf import settings 3 | from django.db import transaction 4 | from django.http import HttpResponse 5 | from django.views.decorators.csrf import csrf_exempt 6 | from nudge import server 7 | 8 | @csrf_exempt 9 | @transaction.commit_on_success 10 | def batch(request): 11 | key = settings.NUDGE_KEY.decode('hex') 12 | result = server.process_batch(key, request.POST['batch'], request.POST['iv']) 13 | return HttpResponse(result) 14 | 15 | 16 | @csrf_exempt 17 | def check_versions(request): 18 | keys = json.loads(request.POST['keys']) 19 | result = server.versions(keys) 20 | return HttpResponse(result) 21 | --------------------------------------------------------------------------------