-
39 | {% block object-tools-items %}
40 |
- {% trans "History" %} 42 | {% if has_absolute_url %} 43 |
- 44 | {% trans "View on site" %} 46 | {% endif %} 47 | {% endblock %} 48 |
├── .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 |
Item Name | 61 |Revision Type | 62 |Date | 63 |status | 64 |
---|
No changes in this batch!
82 | 83 | {% endif %} 84 | {% endblock %} 85 | 86 | 87 | 88 | 89 |Type | 11 |Name | 12 |
{{ version.content_type.name }} | 16 |{{ version.object_repr }} | 17 | 18 | {% endfor %} 19 |
10 | {% trans "Delete" %} 11 |
{% 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 |{{ model.name }} 29 | | 30 | {% else %} 31 |{{ model.name }} | 32 | {% endif %} 33 | {% if model.perms.add %} 34 | {% if not model.name == 'Settings' %} 35 |{% trans 'Add' %} 37 | | 38 | {% else %} 39 |40 | {% endif %} 41 | {% endif %} 42 | {% if model.perms.change %} 43 | | {% trans 'Change' %} 45 | | 46 | {% else %} 47 |48 | {% endif %} 49 | |
---|
{% trans "You don't have permission to edit anything." %}
56 | {% endif %} 57 |