diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..8eaf548 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[run] +include = + publish/* diff --git a/.gitignore b/.gitignore index b7de798..90bc727 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ *.pyc *.swp *.db +.idea diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..dc4eb7f --- /dev/null +++ b/.travis.yml @@ -0,0 +1,17 @@ +language: python + +python: + - 2.7 + +env: + - DJANGO_VERSION=1.4.8 + - DJANGO_VERSION=1.5.1 + +install: + - make setup + - pip install django==$DJANGO_VERSION + +script: make test + +after_success: + - coveralls diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1e7414e --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +help: + @grep '^[^#[:space:]].*:' Makefile | awk -F ":" '{print $$1}' + +clean: + @find . -name "*.pyc" -delete + +deps: + @pip install -r requirements.txt + @pip install -r requirements_test.txt + +setup: deps + +test: clean deps + @nosetests -s -v --with-coverage \ No newline at end of file diff --git a/README.rst b/README.rst index 3636471..e3fdd76 100644 --- a/README.rst +++ b/README.rst @@ -2,6 +2,19 @@ Django Publish ============== +.. image:: https://travis-ci.org/petry/django-publish.png?branch=master + :target: https://travis-ci.org/petry/django-publish + :alt: CI status on Travis CI + +.. image:: https://codeq.io/github/petry/django-publish/badges/master.png + :target: https://codeq.io/github/petry/django-publish/branches/master + :alt: Quality score on Codeq + +.. image:: https://coveralls.io/repos/petry/django-publish/badge.png?branch=master + :target: https://coveralls.io/r/petry/django-publish + :alt: Coverage Status + + Handy mixin/abstract class for providing a "publisher workflow" to arbitrary Django_ models. Overview @@ -187,7 +200,11 @@ To run the tests for this app use the script: :: - tests/run_tests.sh + $ make test + + +or simply ``$ nosetests`` on *publish* folder + .. _Django: http://www.djangoproject.com/ diff --git a/examplecms/__init__.py b/examplecms/__init__.py new file mode 100644 index 0000000..ea35eb4 --- /dev/null +++ b/examplecms/__init__.py @@ -0,0 +1 @@ +__author__ = 'petry' diff --git a/examplecms/examplecms/__init__.py b/examplecms/examplecms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examplecms/examplecms/media/images/next_landmark_2012-02.jpg b/examplecms/examplecms/media/images/next_landmark_2012-02.jpg new file mode 100644 index 0000000..e557b91 Binary files /dev/null and b/examplecms/examplecms/media/images/next_landmark_2012-02.jpg differ diff --git a/examplecms/examplecms/settings.py b/examplecms/examplecms/settings.py new file mode 100644 index 0000000..dfaa914 --- /dev/null +++ b/examplecms/examplecms/settings.py @@ -0,0 +1,113 @@ +import os +PROJECT_PATH = os.path.abspath(os.path.dirname(__file__)) + +DEBUG = True +TEMPLATE_DEBUG = DEBUG + +ADMINS = ( +) + +MANAGERS = ADMINS + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': 'example.db', + 'USER': '', + 'PASSWORD': '', + 'HOST': '', + 'PORT': '', + } +} + +ALLOWED_HOSTS = [] + +TIME_ZONE = 'America/Chicago' + +LANGUAGE_CODE = 'en-us' + +SITE_ID = 1 + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +MEDIA_ROOT = os.path.join(PROJECT_PATH, 'media') + +MEDIA_URL = '/media/' + +STATIC_ROOT = '' + +STATIC_URL = '/static/' + +STATICFILES_DIRS = ( +) + +STATICFILES_FINDERS = ( + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', +) + +SECRET_KEY = '6ec5dt14-0cxe!hha)um#y10$9o#r&p&mf&h%y9oj8dc@_el-j' + +TEMPLATE_LOADERS = ( + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', +) + +MIDDLEWARE_CLASSES = ( + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.contrib.flatpages.middleware.FlatpageFallbackMiddleware', +) + +ROOT_URLCONF = 'examplecms.urls' + +WSGI_APPLICATION = 'examplecms.wsgi.application' + +TEMPLATE_DIRS = ( + os.path.join(PROJECT_PATH, 'templates'), +) + +INSTALLED_APPS = ( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.contrib.flatpages', + 'django.contrib.admin', + + 'publish', + 'pubcms', +) + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'filters': { + 'require_debug_false': { + '()': 'django.utils.log.RequireDebugFalse' + } + }, + 'handlers': { + 'mail_admins': { + 'level': 'ERROR', + 'filters': ['require_debug_false'], + 'class': 'django.utils.log.AdminEmailHandler' + } + }, + 'loggers': { + 'django.request': { + 'handlers': ['mail_admins'], + 'level': 'ERROR', + 'propagate': True, + }, + } +} diff --git a/examplecms/examplecms/urls.py b/examplecms/examplecms/urls.py new file mode 100644 index 0000000..3d2d1c2 --- /dev/null +++ b/examplecms/examplecms/urls.py @@ -0,0 +1,24 @@ +from django.conf import settings +from django.conf.urls import patterns, include, url + +# Uncomment the next two lines to enable the admin: +from django.contrib import admin +admin.autodiscover() + +urlpatterns = patterns( + '', + # Examples: + # url(r'^$', 'examplecms.views.home', name='home'), + # url(r'^examplecms/', include('examplecms.foo.urls')), + + # Uncomment the admin/doc line below to enable admin documentation: + # url(r'^admin/doc/', include('django.contrib.admindocs.urls')), + + # Uncomment the next line to enable the admin: + (r'^media/(?P.*)$', 'django.views.static.serve', + {'document_root': settings.MEDIA_ROOT, + 'show_indexes': True}), + + url(r'^admin/', include(admin.site.urls)), + ('^', include('pubcms.urls')), +) diff --git a/examplecms/examplecms/wsgi.py b/examplecms/examplecms/wsgi.py new file mode 100644 index 0000000..c9f08b2 --- /dev/null +++ b/examplecms/examplecms/wsgi.py @@ -0,0 +1,32 @@ +""" +WSGI config for examplecms project. + +This module contains the WSGI application used by Django's development server +and any production WSGI deployments. It should expose a module-level variable +named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover +this application via the ``WSGI_APPLICATION`` setting. + +Usually you will have the standard Django WSGI application here, but it also +might make sense to replace the whole Django WSGI application with a custom one +that later delegates to the Django one. For example, you could introduce WSGI +middleware here, or combine a Django application with an application of another +framework. + +""" +import os + +# We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks +# if running multiple sites in the same mod_wsgi process. To fix this, use +# mod_wsgi daemon mode with each site in its own daemon process, or use +# os.environ["DJANGO_SETTINGS_MODULE"] = "examplecms.settings" +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "examplecms.settings") + +# This application object is used by any WSGI server configured to use this +# file. This includes Django's development server, if the WSGI_APPLICATION +# setting points here. +from django.core.wsgi import get_wsgi_application +application = get_wsgi_application() + +# Apply WSGI middleware here. +# from helloworld.wsgi import HelloWorldApplication +# application = HelloWorldApplication(application) diff --git a/examplecms/manage.py b/examplecms/manage.py new file mode 100644 index 0000000..806b60e --- /dev/null +++ b/examplecms/manage.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "examplecms.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/examplecms/pubcms/admin.py b/examplecms/pubcms/admin.py index 1122c72..7200c67 100644 --- a/examplecms/pubcms/admin.py +++ b/examplecms/pubcms/admin.py @@ -3,19 +3,22 @@ from publish.admin import PublishableAdmin, PublishableStackedInline from pubcms.models import Page, PageBlock, Category, Image + class PageBlockInlineAdmin(PublishableStackedInline): model = PageBlock extra = 1 + class PageAdmin(PublishableAdmin): inlines = [PageBlockInlineAdmin] prepopulated_fields = {"slug": ("title",)} list_filter = ['publish_state', 'categories'] + class CategoryAdmin(PublishableAdmin): prepopulated_fields = {"slug": ("name",)} + admin.site.register(Page, PageAdmin) admin.site.register(Category, CategoryAdmin) admin.site.register(Image, PublishableAdmin) - diff --git a/examplecms/pubcms/models.py b/examplecms/pubcms/models.py index c46e74a..032f1e8 100644 --- a/examplecms/pubcms/models.py +++ b/examplecms/pubcms/models.py @@ -2,16 +2,17 @@ from django.core.urlresolvers import reverse as reverse_url from publish.models import Publishable + class Page(Publishable): title = models.CharField(max_length=200) - slug = models.CharField(max_length=100, db_index=True) - + slug = models.CharField(max_length=100, db_index=True) + parent = models.ForeignKey('self', blank=True, null=True) categories = models.ManyToManyField('Category', blank=True) class PublishMeta(Publishable.PublishMeta): - publish_reverse_fields=['pageblock_set'] + publish_reverse_fields = ['pageblock_set'] def __unicode__(self): return self.title @@ -30,18 +31,21 @@ def get_absolute_url(self): else: return reverse_url('draft_page_detail', args=[url]) + class PageBlock(Publishable): page = models.ForeignKey(Page) content = models.TextField(blank=True) image = models.ForeignKey('Image', blank=True, null=True) + class Image(Publishable): title = models.CharField(max_length=100) image = models.ImageField(upload_to='images/') - + def __unicode__(self): return self.title + class Category(Publishable): name = models.CharField(max_length=200) slug = models.CharField(max_length=100, db_index=True) diff --git a/examplecms/templates/pubcms/page_detail.html b/examplecms/pubcms/templates/pubcms/page_detail.html similarity index 100% rename from examplecms/templates/pubcms/page_detail.html rename to examplecms/pubcms/templates/pubcms/page_detail.html diff --git a/examplecms/pubcms/urls.py b/examplecms/pubcms/urls.py index f22bf5b..e658bd7 100644 --- a/examplecms/pubcms/urls.py +++ b/examplecms/pubcms/urls.py @@ -1,10 +1,14 @@ -from django.conf.urls.defaults import * -from django.conf import settings +from django.conf.urls import patterns, url -from views import page_detail -from models import Page +from pubcms.views import page_detail +from pubcms.models import Page -urlpatterns = patterns('', - url('^(?P.*)\*$', page_detail, { 'queryset': Page.objects.draft() }, name='draft_page_detail'), - url('^(?P.*)$', page_detail, { 'queryset': Page.objects.published() }, name='public_page_detail'), +urlpatterns = patterns( + '', + url('^(?P.*)\*$', page_detail, + {'queryset': Page.objects.draft()}, + name='draft_page_detail'), + url('^(?P.*)$', page_detail, + {'queryset': Page.objects.published()}, + name='public_page_detail'), ) diff --git a/examplecms/pubcms/views.py b/examplecms/pubcms/views.py index 746e38c..a3e3bad 100644 --- a/examplecms/pubcms/views.py +++ b/examplecms/pubcms/views.py @@ -1,6 +1,5 @@ from django.shortcuts import render_to_response, get_object_or_404 -from models import Page def page_detail(request, page_url, queryset): parts = page_url.split('/') @@ -10,6 +9,6 @@ def page_detail(request, page_url, queryset): for slug in parts: filter_params[field] = slug field = 'parent__%s' % field - page = get_object_or_404(queryset,**filter_params) - - return render_to_response("pubcms/page_detail.html", { 'page': page }) + page = get_object_or_404(queryset, **filter_params) + + return render_to_response("pubcms/page_detail.html", {'page': page}) diff --git a/examplecms/runserver.sh b/examplecms/runserver.sh deleted file mode 100755 index 3f2e58b..0000000 --- a/examplecms/runserver.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -# run from parent directory -django-admin.py runserver --pythonpath=. --pythonpath=examplecms --settings=settings diff --git a/examplecms/settings.py b/examplecms/settings.py deleted file mode 100644 index b2e1003..0000000 --- a/examplecms/settings.py +++ /dev/null @@ -1,89 +0,0 @@ -import os -PROJECT_PATH = os.path.abspath(os.path.dirname(__file__)) - -DEBUG = True -TEMPLATE_DEBUG = DEBUG - -ADMINS = ( - # ('Your Name', 'your_email@domain.com'), -) - -MANAGERS = ADMINS - -DATABASE_ENGINE = 'sqlite3' -DATABASE_NAME = os.path.join(PROJECT_PATH, 'example.db') -DATABASE_USER = '' # Not used with sqlite3. -DATABASE_PASSWORD = '' # Not used with sqlite3. -DATABASE_HOST = '' # Set to empty string for localhost. Not used with sqlite3. -DATABASE_PORT = '' # Set to empty string for default. Not used with sqlite3. - -# Local time zone for this installation. Choices can be found here: -# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name -# although not all choices may be available on all operating systems. -# If running in a Windows environment this must be set to the same as your -# system time zone. -TIME_ZONE = 'America/Chicago' - -# Language code for this installation. All choices can be found here: -# http://www.i18nguy.com/unicode/language-identifiers.html -LANGUAGE_CODE = 'en-us' - -SITE_ID = 1 - -# If you set this to False, Django will make some optimizations so as not -# to load the internationalization machinery. -USE_I18N = True - -# Relative file structure #3 -# Absolute path to the directory that holds media. -# Example: "/home/media/media.lawrence.com/" -# MEDIA_ROOT = "Z:/development/agfecms/media/" -MEDIA_ROOT = os.path.join(PROJECT_PATH, 'media') - - -# URL that handles the media served from MEDIA_ROOT. Make sure to use a -# trailing slash if there is a path component (optional in other cases). -# Examples: "http://media.lawrence.com" -MEDIA_URL = '/media/' - -# URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a -# trailing slash. -# Examples: "http://foo.com/media/", "/media/". -ADMIN_MEDIA_PREFIX = '/admin_media/' - -# Make this unique, and don't share it with anybody. -SECRET_KEY = '+9qi8mm2ddo&wb@0s(@9wfdhxmpe2cxh!cb3@7yd9ao13-s6ea' - -# List of callables that know how to import templates from various sources. -TEMPLATE_LOADERS = ( - 'django.template.loaders.filesystem.load_template_source', - 'django.template.loaders.app_directories.load_template_source', -# 'django.template.loaders.eggs.load_template_source', -) - -MIDDLEWARE_CLASSES = ( - 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.flatpages.middleware.FlatpageFallbackMiddleware', #must be the last entry -) - -ROOT_URLCONF = 'urls' - - -# Relative file structure #2 -TEMPLATE_DIRS = ( - os.path.join(PROJECT_PATH, 'templates'), -) - -INSTALLED_APPS = ( - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.sites', - 'django.contrib.flatpages', - 'django.contrib.admin', - 'pubcms', - 'publish', -) - diff --git a/examplecms/shell.sh b/examplecms/shell.sh deleted file mode 100755 index 999ef07..0000000 --- a/examplecms/shell.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -# run from parent directory -django-admin.py shell --pythonpath=. --pythonpath=examplecms --settings=settings diff --git a/examplecms/syncdb.sh b/examplecms/syncdb.sh deleted file mode 100755 index a04e4b8..0000000 --- a/examplecms/syncdb.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -# run from parent directory -django-admin.py syncdb --pythonpath=. --pythonpath=examplecms --settings=settings diff --git a/examplecms/urls.py b/examplecms/urls.py deleted file mode 100644 index 12a870f..0000000 --- a/examplecms/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -from django.conf.urls.defaults import * -from django.conf import settings - -# Uncomment the next two lines to enable the admin: -from django.contrib import admin -admin.autodiscover() - -urlpatterns = patterns('', - ('^admin/', include(admin.site.urls)), - - (r'^media/(?P.*)$', 'django.views.static.serve', {'document_root': settings.MEDIA_ROOT, 'show_indexes': True}), - - ('^', include('pubcms.urls')), -) diff --git a/publish/actions.py b/publish/actions.py index c889925..83080c4 100644 --- a/publish/actions.py +++ b/publish/actions.py @@ -8,14 +8,16 @@ from django.utils.safestring import mark_safe from django.utils.text import capfirst from django.utils.translation import ugettext as _ -from django.contrib.admin.actions import delete_selected as django_delete_selected +from django.contrib.admin.actions import delete_selected as \ + django_delete_selected from models import Publishable from utils import NestedSet + def _get_change_view_url(app_label, object_name, pk, levels_to_root): - return '%s%s/%s/%s/' % ('../'*levels_to_root, app_label, - object_name, quote(pk)) + return '%s%s/%s/%s/' % ('../' * levels_to_root, app_label, + object_name, quote(pk)) def delete_selected(modeladmin, request, queryset): @@ -24,7 +26,11 @@ def delete_selected(modeladmin, request, queryset): if not modeladmin.has_delete_permission(request, obj): raise PermissionDenied return django_delete_selected(modeladmin, request, queryset) -delete_selected.short_description = "Mark %(verbose_name_plural)s for deletion" + + +delete_selected.short_description = \ + "Mark %(verbose_name_plural)s for deletion" + def undelete_selected(modeladmin, request, queryset): for obj in queryset: @@ -33,35 +39,43 @@ def undelete_selected(modeladmin, request, queryset): for obj in queryset: obj.undelete() return None -undelete_selected.short_description = "Un-mark %(verbose_name_plural)s for deletion" + + +undelete_selected.short_description = \ + "Un-mark %(verbose_name_plural)s for deletion" + def _get_publishable_html(admin_site, levels_to_root, value): model = value.__class__ model_name = escape(capfirst(model._meta.verbose_name)) model_title = escape(force_unicode(value)) model_text = '%s: %s' % (model_name, model_title) - opts = model._meta + opts = model._meta has_admin = model in admin_site._registry if has_admin: modeladmin = admin_site._registry[model] - model_text = '%s (%s)' % (model_text, modeladmin.get_publish_status_display(value)) + model_text = '%s (%s)' % ( + model_text, modeladmin.get_publish_status_display(value) + ) url = _get_change_view_url(opts.app_label, opts.object_name.lower(), value._get_pk_val(), - levels_to_root) + levels_to_root) html_value = mark_safe(u'%s' % (url, model_text)) else: html_value = mark_safe(model_text) - + return html_value + def _to_html(admin_site, items): levels_to_root = 2 html_list = [] for value in items: if isinstance(value, Publishable): - html_value = _get_publishable_html(admin_site, levels_to_root, value) + html_value = _get_publishable_html(admin_site, levels_to_root, + value) else: html_value = _to_html(admin_site, value) html_list.append(html_value) @@ -71,12 +85,13 @@ def _to_html(admin_site, items): def _convert_all_published_to_html(admin_site, all_published): return _to_html(admin_site, all_published.nested_items()) + def _check_permissions(modeladmin, all_published, request, perms_needed): admin_site = modeladmin.admin_site for instance in all_published: model = instance.__class__ - other_modeladmin = admin_site._registry.get(model,None) + other_modeladmin = admin_site._registry.get(model, None) if other_modeladmin: if not other_modeladmin.has_publish_permission(request, instance): perms_needed.append(instance) @@ -90,14 +105,13 @@ def _root_path(admin_site): def publish_selected(modeladmin, request, queryset): opts = modeladmin.model._meta app_label = opts.app_label - + all_published = NestedSet() for obj in queryset: obj.publish(dry_run=True, all_published=all_published) perms_needed = [] _check_permissions(modeladmin, all_published, request, perms_needed) - if request.POST.get('post'): if perms_needed: raise PermissionDenied @@ -108,19 +122,22 @@ def publish_selected(modeladmin, request, queryset): modeladmin.log_publication(request, object) queryset.publish() - - modeladmin.message_user(request, _("Successfully published %(count)d %(items)s.") % { - "count": n, "items": model_ngettext(modeladmin.opts, n) - }) + + message = _("Successfully published %(count)d %(items)s.") % { + "count": n, + "items": model_ngettext(modeladmin.opts, n) + } + modeladmin.message_user(request, message) # Return None to display the change list page again. return None - + admin_site = modeladmin.admin_site - + context = { "title": _("Publish?"), "object_name": force_unicode(opts.verbose_name), - "all_published": _convert_all_published_to_html(admin_site, all_published), + "all_published": _convert_all_published_to_html(admin_site, + all_published), "perms_lacking": _to_html(admin_site, perms_needed), 'queryset': queryset, "opts": opts, @@ -131,7 +148,8 @@ def publish_selected(modeladmin, request, queryset): # Display the confirmation page return render_to_response(modeladmin.publish_confirmation_template or [ - "admin/%s/%s/publish_selected_confirmation.html" % (app_label, opts.object_name.lower()), + "admin/%s/%s/publish_selected_confirmation.html" % ( + app_label, opts.object_name.lower()), "admin/%s/publish_selected_confirmation.html" % app_label, "admin/publish_selected_confirmation.html" ], context, context_instance=template.RequestContext(request)) diff --git a/publish/admin.py b/publish/admin.py index be48cbf..40a89dc 100644 --- a/publish/admin.py +++ b/publish/admin.py @@ -1,11 +1,18 @@ from django.contrib import admin +from django.contrib import messages +from django.contrib.admin.util import unquote +from django.db import transaction from django.forms.models import BaseInlineFormSet -from django.utils.encoding import force_unicode +from django.http import HttpResponseRedirect +from django.utils.encoding import force_unicode, force_text +from django.utils.translation import ugettext as _ from .models import Publishable from .actions import publish_selected, delete_selected, undelete_selected from publish.filters import register_filters +from publish.utils import NestedSet + register_filters() @@ -16,7 +23,7 @@ def _make_form_readonly(form): if hasattr(widget, 'widget'): widget = getattr(widget, 'widget') widget.attrs['disabled'] = 'disabled' - + def _make_adminform_readonly(adminform, inline_admin_formsets): _make_form_readonly(adminform.form) @@ -30,33 +37,38 @@ def _draft_queryset(db_field, kwargs): model = db_field.rel.to if issubclass(model, Publishable): kwargs['queryset'] = model._default_manager.draft() \ - .complex_filter(db_field.rel.limit_choices_to) + .complex_filter(db_field.rel.limit_choices_to) def attach_filtered_formfields(admin_class): - # class decorator to add in extra methods that + # class decorator to add in extra methods that # are common to several classes super_formfield_for_foreignkey = admin_class.formfield_for_foreignkey + def formfield_for_foreignkey(self, db_field, request=None, **kwargs): _draft_queryset(db_field, kwargs) - return super_formfield_for_foreignkey(self, db_field, request, **kwargs) + return super_formfield_for_foreignkey(self, db_field, request, + **kwargs) + admin_class.formfield_for_foreignkey = formfield_for_foreignkey - + super_formfield_for_manytomany = admin_class.formfield_for_manytomany + def formfield_for_manytomany(self, db_field, request=None, **kwargs): _draft_queryset(db_field, kwargs) - return super_formfield_for_manytomany(self, db_field, request, **kwargs) + return super_formfield_for_manytomany(self, db_field, request, + **kwargs) + admin_class.formfield_for_manytomany = formfield_for_manytomany return admin_class class PublishableAdmin(admin.ModelAdmin): - actions = [publish_selected, delete_selected, undelete_selected] change_form_template = 'admin/publish_change_form.html' publish_confirmation_template = None deleted_form_template = None - + list_display = ['__unicode__', 'publish_state'] list_filter = ['publish_state'] @@ -71,30 +83,36 @@ def get_actions(self, request): actions = super(PublishableAdmin, self).get_actions(request) # replace site-wide delete selected with our own version if 'delete_selected' in actions: - actions['delete_selected'] = (delete_selected, 'delete_selected', delete_selected.short_description) + actions['delete_selected'] = (delete_selected, 'delete_selected', + delete_selected.short_description) return actions def has_change_permission(self, request, obj=None): # user can never change public models directly # but can view old read-only copy of it if we are about to delete it if obj: - if obj.is_public or (request.method == 'POST' and obj.publish_state == Publishable.PUBLISH_DELETE): + if obj.is_public \ + or (request.method == 'POST' + and obj.publish_state == Publishable.PUBLISH_DELETE): return False - return super(PublishableAdmin, self).has_change_permission(request, obj) - + return super(PublishableAdmin, self).has_change_permission(request, + obj) + def has_delete_permission(self, request, obj=None): # use can never delete models directly if obj and obj.is_public: return False - return super(PublishableAdmin, self).has_delete_permission(request, obj) - + return super(PublishableAdmin, self).has_delete_permission(request, + obj) + def has_undelete_permission(self, request, obj=None): return self.has_publish_permission(request, obj=obj) def has_publish_permission(self, request, obj=None): opts = self.opts - return request.user.has_perm(opts.app_label + '.' + opts.get_publish_permission()) - + return request.user.has_perm( + opts.app_label + '.' + opts.get_publish_permission()) + def get_publish_status_display(self, obj): state = obj.get_publish_state_display() if not obj.is_public and not obj.public: @@ -106,31 +124,63 @@ def log_publication(self, request, object): if isinstance(object, Publishable): model = object.__class__ other_modeladmin = self.admin_site._registry.get(model, None) - if other_modeladmin: - # just log as a change + if other_modeladmin: + # just log as a change self.log_change(request, object, 'Published') - def render_change_form(self, request, context, add=False, change=False, form_url='', obj=None): - context['has_publish_permission'] = self.has_publish_permission(request, obj) + def render_change_form(self, request, context, add=False, change=False, + form_url='', obj=None): + context['has_publish_permission'] = self.has_publish_permission( + request, obj) if obj and obj.publish_state == Publishable.PUBLISH_DELETE: - adminform, inline_admin_formsets = context['adminform'], context['inline_admin_formsets'] + adminform, inline_admin_formsets = context['adminform'], context[ + 'inline_admin_formsets'] _make_adminform_readonly(adminform, inline_admin_formsets) - + context.update({ - 'title': 'This %s will be deleted' % force_unicode(self.opts.verbose_name), + 'title': 'This %s will be deleted' % force_unicode( + self.opts.verbose_name), }) - - return super(PublishableAdmin, self).render_change_form(request, context, add, change, form_url, obj) + + return super(PublishableAdmin, self).render_change_form(request, + context, add, + change, + form_url, obj) + + def response_publish(self, request, obj): + opts = self.model._meta + msg_dict = {'name': force_text(opts.verbose_name), + 'obj': force_text(obj)} + + msg = _('The %(name)s "%(obj)s" was published successfully') % msg_dict + + messages.success(request, msg, fail_silently=True) + return HttpResponseRedirect(request.path) + + @transaction.commit_on_success + def change_view(self, request, object_id, form_url='', extra_context=None): + if request.method == "POST" and "_publish" in request.POST: + obj = self.get_object(request, unquote(object_id)) + + all_published = NestedSet() + obj.publish(all_published=all_published) + self.log_publication(request, obj) + + return self.response_publish(request, obj) + return super(PublishableAdmin, self).change_view(request, object_id, + form_url, + extra_context) class PublishableBaseInlineFormSet(BaseInlineFormSet): # we will actually delete inline objects, rather than # just marking them for deletion, as they are like # an edit to their parent - + def save_existing_objects(self, commit=True): - saved_instances = super(PublishableBaseInlineFormSet, self).save_existing_objects(commit=commit) + saved_instances = super(PublishableBaseInlineFormSet, + self).save_existing_objects(commit=commit) for obj in self.deleted_objects: if obj.pk is not None: obj.delete(mark_for_deletion=False) @@ -146,5 +196,6 @@ class PublishableTabularInline(admin.TabularInline): # add in extra methods -for admin_class in [PublishableAdmin, PublishableStackedInline, PublishableTabularInline]: +for admin_class in [PublishableAdmin, PublishableStackedInline, + PublishableTabularInline]: attach_filtered_formfields(admin_class) diff --git a/publish/filters.py b/publish/filters.py index 6cd0c65..0aee561 100644 --- a/publish/filters.py +++ b/publish/filters.py @@ -1,21 +1,7 @@ from django.utils.encoding import smart_unicode - from .models import Publishable - - -try: - from django.contrib.admin.filters import FieldListFilter, RelatedFieldListFilter -except ImportError: - # only using this code if on before Django 1.4 - from django.contrib.admin.filterspecs import FilterSpec, RelatedFilterSpec as RelatedFieldListFilter - - class FieldListFilter(object): - @classmethod - def register(cls, test, list_filter_class, take_priority=False): - if take_priority: - FilterSpec.filter_specs.insert(0, (test, list_filter_class)) - else: - FilterSpec.filter_specs.append((test, list_filter_class)) +from django.contrib.admin.filters import FieldListFilter, \ + RelatedFieldListFilter def is_publishable_filter(f): @@ -24,17 +10,24 @@ def is_publishable_filter(f): class PublishableRelatedFieldListFilter(RelatedFieldListFilter): def __init__(self, field, request, params, model, model_admin, *arg, **kw): - super(PublishableRelatedFieldListFilter, self).__init__(field, request, params, model, model_admin, *arg, **kw) - # to keep things simple we'll just remove all "non-draft" instance from list + super(PublishableRelatedFieldListFilter, self).__init__(field, request, + params, model, + model_admin, + *arg, **kw) + # to keep things simple we'll just remove all "non-draft" + # instance from list rel_model = field.rel.to - queryset = rel_model._default_manager.complex_filter(field.rel.limit_choices_to).draft_and_deleted() + queryset = rel_model._default_manager.complex_filter( + field.rel.limit_choices_to).draft_and_deleted() if hasattr(field.rel, 'get_related_field'): - lst = [(getattr(x, field.rel.get_related_field().attname), smart_unicode(x)) for x in queryset] + lst = [(getattr(x, field.rel.get_related_field().attname), + smart_unicode(x)) for x in queryset] else: lst = [(x._get_pk_val(), smart_unicode(x)) for x in queryset] self.lookup_choices = lst def register_filters(): - FieldListFilter.register(is_publishable_filter, PublishableRelatedFieldListFilter, take_priority=True) - + FieldListFilter.register(is_publishable_filter, + PublishableRelatedFieldListFilter, + take_priority=True) diff --git a/publish/models.py b/publish/models.py index 7efaae3..b1a93fe 100644 --- a/publish/models.py +++ b/publish/models.py @@ -2,7 +2,6 @@ from django.db.models.query import QuerySet, Q from django.db.models.base import ModelBase from django.db.models.fields.related import RelatedField -from django.conf import settings from utils import NestedSet from signals import pre_publish, post_publish @@ -11,18 +10,19 @@ # django-cms 2.0 # e.g. http://github.com/digi604/django-cms-2.0/blob/master/publisher/models.py # -# but we want this to be a reusable/standalone app and have a few different needs -# +# but we want this to be a reusable/standalone app and have a few different +# needs + class PublishException(Exception): pass -class PublishableQuerySet(QuerySet): +class PublishableQuerySet(QuerySet): def changed(self): '''all draft objects that have not been published yet''' return self.filter(Publishable.Q_CHANGED) - + def deleted(self): '''public objects that need deleting''' return self.filter(Publishable.Q_DELETED) @@ -30,16 +30,18 @@ def deleted(self): def draft(self): '''all draft objects''' return self.filter(Publishable.Q_DRAFT) - + def draft_and_deleted(self): return self.filter(Publishable.Q_DRAFT | Publishable.Q_DELETED) - + def published(self): '''all public/published objects''' return self.filter(Publishable.Q_PUBLISHED) def publish(self, all_published=None): - '''publish all models in this queryset''' + ''' + publish all models in this queryset + ''' if all_published is None: all_published = NestedSet() for p in self: @@ -47,79 +49,85 @@ def publish(self, all_published=None): def delete(self, mark_for_deletion=True): ''' - override delete so that we call delete on each object separately, as delete needs - to set some flags etc + override delete so that we call delete on each object separately, + as delete needs to set some flags etc ''' for p in self: p.delete(mark_for_deletion=mark_for_deletion) class PublishableManager(models.Manager): - def get_query_set(self): return PublishableQuerySet(self.model) def changed(self): '''all draft objects that have not been published yet''' return self.get_query_set().changed() - + def deleted(self): '''public objects that need deleting''' return self.get_query_set().deleted() - + def draft(self): '''all draft objects''' return self.get_query_set().draft() - + def draft_and_deleted(self): - return self.get_query_set().draft_and_deleted() - + return self.get_query_set().draft_and_deleted() + def published(self): '''all public/published objects''' return self.get_query_set().published() class PublishableBase(ModelBase): - def __new__(cls, name, bases, attrs): - new_class = super(PublishableBase, cls).__new__(cls, name, bases, attrs) - # insert an extra permission in for "Can publish" - # as well as a "method" to find name of publish_permission for this object + new_class = super(PublishableBase, cls).__new__(cls, name, bases, + attrs) + # insert an extra permission in for "Can publish" as well as a + # "method" to find name of publish_permission for this object opts = new_class._meta name = u'Can publish %s' % opts.verbose_name code = u'publish_%s' % opts.object_name.lower() opts.permissions = tuple(opts.permissions) + ((code, name), ) opts.get_publish_permission = lambda: code - + return new_class - + class Publishable(models.Model): __metaclass__ = PublishableBase PUBLISH_DEFAULT = 0 PUBLISH_CHANGED = 1 - PUBLISH_DELETE = 2 + PUBLISH_DELETE = 2 - PUBLISH_CHOICES = ((PUBLISH_DEFAULT, 'Published'), (PUBLISH_CHANGED, 'Changed'), (PUBLISH_DELETE, 'To be deleted')) + PUBLISH_CHOICES = ( + (PUBLISH_DEFAULT, 'Published'), (PUBLISH_CHANGED, 'Changed'), + (PUBLISH_DELETE, 'To be deleted')) # make these available here so can easily re-use them in other code Q_PUBLISHED = Q(is_public=True) - Q_DRAFT = Q(is_public=False) & ~Q(publish_state=PUBLISH_DELETE) - Q_CHANGED = Q(is_public=False, publish_state=PUBLISH_CHANGED) - Q_DELETED = Q(is_public=False, publish_state=PUBLISH_DELETE) - - is_public = models.BooleanField(default=False, editable=False, db_index=True) - publish_state = models.IntegerField('Publication status', editable=False, db_index=True, choices=PUBLISH_CHOICES, default=PUBLISH_DEFAULT) - public = models.OneToOneField('self', related_name='draft', null=True, editable=False) - + Q_DRAFT = Q(is_public=False) & ~Q(publish_state=PUBLISH_DELETE) + Q_CHANGED = Q(is_public=False, publish_state=PUBLISH_CHANGED) + Q_DELETED = Q(is_public=False, publish_state=PUBLISH_DELETE) + + is_public = models.BooleanField(default=False, editable=False, + db_index=True) + publish_state = models.IntegerField('Publication status', editable=False, + db_index=True, choices=PUBLISH_CHOICES, + default=PUBLISH_DEFAULT) + public = models.OneToOneField('self', related_name='draft', null=True, + editable=False) + class Meta: abstract = True class PublishMeta(object): - publish_exclude_fields = ['id', 'is_public', 'publish_state', 'public', 'draft'] + publish_exclude_fields = ['id', 'is_public', 'publish_state', 'public', + 'draft'] publish_reverse_fields = [] - publish_functions = {} + publish_functions = {} @classmethod def _combined_fields(cls, field_name): @@ -139,8 +147,8 @@ def reverse_fields_to_publish(cls): @classmethod def find_publish_function(cls, field_name, default_function): ''' - Search to see if there is a function to copy the given field over. - Function should take same params as setattr() + Search to see if there is a function to copy the given field over. + Function should take same params as setattr() ''' for clazz in cls.__mro__: publish_functions = getattr(clazz, 'publish_functions', {}) @@ -150,7 +158,7 @@ def find_publish_function(cls, field_name, default_function): return default_function objects = PublishableManager() - + def is_marked_for_deletion(self): return self.publish_state == Publishable.PUBLISH_DELETE @@ -162,11 +170,12 @@ def get_public_absolute_url(self): def save(self, mark_changed=True, *arg, **kw): if not self.is_public and mark_changed: if self.publish_state == Publishable.PUBLISH_DELETE: - raise PublishException("Attempting to save model marked for deletion") + raise PublishException( + "Attempting to save model marked for deletion") self.publish_state = Publishable.PUBLISH_CHANGED super(Publishable, self).save(*arg, **kw) - + def delete(self, mark_for_deletion=True): if self.public and mark_for_deletion: self.publish_state = Publishable.PUBLISH_DELETE @@ -189,28 +198,33 @@ def _post_publish(self, dry_run, all_published, deleted=False): # got published (in case it was indirectly published elsewhere) sender = self.__class__ instance = all_published.original(self) - post_publish.send(sender=sender, instance=instance, deleted=deleted) - + post_publish.send(sender=sender, instance=instance, + deleted=deleted) def publish(self, dry_run=False, all_published=None, parent=None): ''' either publish changes or deletions, depending on whether this model is public or draft. - public models will be examined to see if they need deleting and deleted if so. ''' if self.is_public: - raise PublishException("Cannot publish public model - publish should be called from draft model") + raise PublishException( + "Cannot publish public model - publish should be called " + "from draft model") if self.pk is None: raise PublishException("Please save model before publishing") - + if self.publish_state == Publishable.PUBLISH_DELETE: - self.publish_deletions(dry_run=dry_run, all_published=all_published, parent=parent) + self.publish_deletions(dry_run=dry_run, + all_published=all_published, + parent=parent) return None else: - return self.publish_changes(dry_run=dry_run, all_published=all_published, parent=parent) - + return self.publish_changes(dry_run=dry_run, + all_published=all_published, + parent=parent) + def _get_public_or_publish(self, *arg, **kw): # only publish if we don't yet have an id for the # public model @@ -226,22 +240,24 @@ def _get_through_model(self, field_object): In 1.2 through is the class ''' through = field_object.rel.through - if through: - if isinstance(through, basestring): - return field_object.rel.through_model - return through - return None + if not through: + return None + + if isinstance(through, basestring): + return field_object.rel.through_model + return through def publish_changes(self, dry_run=False, all_published=None, parent=None): ''' - publish changes to the model - basically copy all of it's content to another copy in the - database. - if you set dry_run=True nothing will be written to the database. combined with - the all_published value one can therefore get information about what other models - would be affected by this function + publish changes to the model - basically copy all of it's content to + another copy in the database. + if you set dry_run=True nothing will be written to the database. + combined with the all_published value one can therefore get + information about what other models would be affected by this function ''' - assert not self.is_public, "Cannot publish public model - publish should be called from draft model" + assert not self.is_public, "Cannot publish public model - publish " \ + "should be called from draft model" assert self.pk is not None, "Please save model before publishing" # avoid mutual recursion @@ -251,34 +267,38 @@ def publish_changes(self, dry_run=False, all_published=None, parent=None): if self in all_published: return all_published.original(self).public - all_published.add(self, parent=parent) + all_published.add(self, parent=parent) self._pre_publish(dry_run, all_published) public_version = self.public if not public_version: public_version = self.__class__(is_public=True) - + excluded_fields = self.PublishMeta.excluded_fields() - reverse_fields_to_publish = self.PublishMeta.reverse_fields_to_publish() - + reverse_fields_to_publish = \ + self.PublishMeta.reverse_fields_to_publish() + if self.publish_state == Publishable.PUBLISH_CHANGED: # copy over regular fields for field in self._meta.fields: if field.name in excluded_fields: continue - + value = getattr(self, field.name) if isinstance(field, RelatedField): related = field.rel.to if issubclass(related, Publishable): if value is not None: - value = value._get_public_or_publish(dry_run=dry_run, all_published=all_published, parent=self) - + value = value._get_public_or_publish( + dry_run=dry_run, all_published=all_published, + parent=self) + if not dry_run: - publish_function = self.PublishMeta.find_publish_function(field.name, setattr) + publish_function = self.PublishMeta.find_publish_function( + field.name, setattr) publish_function(public_version, field.name, value) - + # save the public version and update # state so we know everything is up-to-date if not dry_run: @@ -286,17 +306,18 @@ def publish_changes(self, dry_run=False, all_published=None, parent=None): self.public = public_version self.publish_state = Publishable.PUBLISH_DEFAULT self.save(mark_changed=False) - + # copy over many-to-many fields for field in self._meta.many_to_many: name = field.name if name in excluded_fields: continue - + m2m_manager = getattr(self, name) public_objs = list(m2m_manager.all()) - field_object, model, direct, m2m = self._meta.get_field_by_name(name) + field_object, model, direct, m2m = self._meta.get_field_by_name( + name) through_model = self._get_through_model(field_object) if through_model: # see if we can work out which reverse relationship this is @@ -307,19 +328,27 @@ def publish_changes(self, dry_run=False, all_published=None, parent=None): for reverse_field in through_model._meta.fields: if reverse_field.column == m2m_reverse_name: related_name = reverse_field.name - related_field = getattr(through_model, related_name).field - reverse_name = related_field.related.get_accessor_name() + related_field = getattr(through_model, + related_name).field + reverse_name = \ + related_field.related.get_accessor_name() reverse_fields_to_publish.append(reverse_name) break - continue # m2m via through table won't be dealt with here + continue # m2m via through table won't be dealt with here related = field_object.rel.to if issubclass(related, Publishable): - public_objs = [p._get_public_or_publish(dry_run=dry_run, all_published=all_published, parent=self) for p in public_objs] - + public_objs = [ + p._get_public_or_publish( + dry_run=dry_run, + all_published=all_published, + parent=self) for p in public_objs + ] + if not dry_run: public_m2m_manager = getattr(public_version, name) - old_objs = public_m2m_manager.exclude(pk__in=[p.pk for p in public_objs]) + old_objs = public_m2m_manager.exclude( + pk__in=[p.pk for p in public_objs]) public_m2m_manager.remove(*old_objs) public_m2m_manager.add(*public_objs) @@ -340,32 +369,36 @@ def publish_changes(self, dry_run=False, all_published=None, parent=None): related_items = [] for related_item in related_items: - related_item.publish(dry_run=dry_run, all_published=all_published, parent=self) - + related_item.publish(dry_run=dry_run, + all_published=all_published, + parent=self) + # make sure we tidy up anything that needs deleting if self.public and not dry_run: if obj.field.rel.multiple: public_ids = [r.public_id for r in related_items] - deleted_items = getattr(self.public, name).exclude(pk__in=public_ids) + deleted_items = getattr(self.public, name).exclude( + pk__in=public_ids) deleted_items.delete(mark_for_deletion=False) - + self._post_publish(dry_run, all_published) return public_version - - def publish_deletions(self, all_published=None, parent=None, dry_run=False): + + def publish_deletions(self, all_published=None, parent=None, + dry_run=False): ''' actually delete models that have been marked for deletion ''' if self.publish_state != Publishable.PUBLISH_DELETE: - return + return if all_published is None: all_published = NestedSet() if self in all_published: return - + all_published.add(self, parent=parent) self._pre_publish(dry_run, all_published, deleted=True) @@ -377,12 +410,14 @@ def publish_deletions(self, all_published=None, parent=None, dry_run=False): if name in self.PublishMeta.excluded_fields(): continue try: + instances = getattr(self, name).all() except AttributeError: instances = [getattr(self, name)] for instance in instances: - instance.publish_deletions(all_published=all_published, parent=self, dry_run=dry_run) - + instance.publish_deletions(all_published=all_published, + parent=self, dry_run=dry_run) + if not dry_run: public = self.public self.delete(mark_for_deletion=False) @@ -390,98 +425,3 @@ def publish_deletions(self, all_published=None, parent=None, dry_run=False): public.delete(mark_for_deletion=False) self._post_publish(dry_run, all_published, deleted=True) - - -if getattr(settings, 'TESTING_PUBLISH', False): - # classes to test that publishing etc work ok - from datetime import datetime - - class Site(models.Model): - title = models.CharField(max_length=100) - domain = models.CharField(max_length=100) - - class FlatPage(Publishable): - url = models.CharField(max_length=100, db_index=True) - title = models.CharField(max_length=200) - content = models.TextField(blank=True) - enable_comments = models.BooleanField() - template_name = models.CharField(max_length=70, blank=True) - registration_required = models.BooleanField() - sites = models.ManyToManyField(Site) - - class Meta: - ordering = ['url'] - - def get_absolute_url(self): - if self.is_public: - return self.url - return '%s*' % self.url - - class Author(Publishable): - name = models.CharField(max_length=100) - profile = models.TextField(blank=True) - - class PublishMeta(Publishable.PublishMeta): - publish_reverse_fields = ['authorprofile'] - - class AuthorProfile(Publishable): - author = models.OneToOneField(Author) - extra_profile = models.TextField(blank=True) - - class ChangeLog(models.Model): - changed = models.DateTimeField(db_index=True, auto_now_add=True) - message = models.CharField(max_length=200) - - class Tag(models.Model): - title = models.CharField(max_length=100, unique=True) - slug = models.CharField(max_length=100) - - # publishable model with a reverse relation to - # page (as a child) - class PageBlock(Publishable): - page=models.ForeignKey('Page') - content = models.TextField(blank=True) - - # non-publishable reverse relation to page (as a child) - class Comment(models.Model): - page=models.ForeignKey('Page') - comment = models.TextField() - - def update_pub_date(page, field_name, value): - # ignore value entirely and replace with now - setattr(page, field_name, update_pub_date.pub_date) - update_pub_date.pub_date = datetime.now() - - class Page(Publishable): - slug = models.CharField(max_length=100, db_index=True) - title = models.CharField(max_length=200) - content = models.TextField(blank=True) - pub_date = models.DateTimeField(default=datetime.now) - - parent = models.ForeignKey('self', blank=True, null=True) - - authors = models.ManyToManyField(Author, blank=True) - log = models.ManyToManyField(ChangeLog, blank=True) - tags = models.ManyToManyField(Tag, through='PageTagOrder', blank=True) - - class Meta: - ordering = ['slug'] - - class PublishMeta(Publishable.PublishMeta): - publish_exclude_fields = ['log'] - publish_reverse_fields = ['pageblock_set'] - publish_functions = { 'pub_date': update_pub_date } - - def get_absolute_url(self): - if not self.parent: - return u'/%s/' % self.slug - return '%s%s/' % (self.parent.get_absolute_url(), self.slug) - - class PageTagOrder(Publishable): - # note these are named in non-standard way to - # ensure we are getting correct names - tagged_page=models.ForeignKey(Page) - page_tag=models.ForeignKey(Tag) - tag_order=models.IntegerField() - - diff --git a/publish/signals.py b/publish/signals.py index e190401..e6a72b1 100644 --- a/publish/signals.py +++ b/publish/signals.py @@ -1,6 +1,6 @@ import django.dispatch -# instance is the instance being published, deleted is a boolean to indicate whether the instance -# was being deleted (rather than changed) -pre_publish = django.dispatch.Signal(providing_args=['instance', 'deleted']) +# instance is the instance being published, deleted is a boolean to indicate +# whether the instance was being deleted (rather than changed) +pre_publish = django.dispatch.Signal(providing_args=['instance', 'deleted']) post_publish = django.dispatch.Signal(providing_args=['instance', 'deleted']) diff --git a/publish/templates/admin/publish_change_form.html b/publish/templates/admin/publish_change_form.html index 387a0df..5008d7a 100644 --- a/publish/templates/admin/publish_change_form.html +++ b/publish/templates/admin/publish_change_form.html @@ -1,5 +1,5 @@ {% extends "admin/change_form.html" %} -{% load i18n admin_modify adminmedia %} +{% load i18n admin_modify publish_admin_tags%} {% block content %}
{% block object-tools %} @@ -46,15 +46,13 @@ {% block after_related_objects %}{% endblock %} -{% if not original or not original.is_marked_for_deletion %}{% submit_row %}{% endif %} - +{% if not original or not original.is_marked_for_deletion %}{% publish_submit_row %}{% endif %} {% if adminform and add %} {% endif %} {# JavaScript for prepopulated fields #} {% prepopulated_fields_js %} -
{% endblock %} diff --git a/publish/templates/admin/publish_submit_line.html b/publish/templates/admin/publish_submit_line.html new file mode 100644 index 0000000..0595370 --- /dev/null +++ b/publish/templates/admin/publish_submit_line.html @@ -0,0 +1,13 @@ + + + + + +
+{% if show_save %}{% endif %} +{% if show_publish %}{% endif %} +{% if show_delete_link %}{% endif %} +{% if show_save_as_new %}{%endif%} +{% if show_save_and_add_another %}{% endif %} +{% if show_save_and_continue %}{% endif %} +
diff --git a/publish/templatetags/__init__.py b/publish/templatetags/__init__.py new file mode 100644 index 0000000..faa18be --- /dev/null +++ b/publish/templatetags/__init__.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- diff --git a/publish/templatetags/publish_admin_tags.py b/publish/templatetags/publish_admin_tags.py new file mode 100644 index 0000000..62eb0ef --- /dev/null +++ b/publish/templatetags/publish_admin_tags.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from django import template +from django.contrib.admin.templatetags.admin_modify import submit_row + +register = template.Library() + + +@register.inclusion_tag('admin/publish_submit_line.html', takes_context=True) +def publish_submit_row(context): + ctx = submit_row(context) + ctx['show_publish'] = context.get('has_publish_permission') \ + and context.get('change') + + return ctx diff --git a/publish/tests.py b/publish/tests.py deleted file mode 100644 index 87728fc..0000000 --- a/publish/tests.py +++ /dev/null @@ -1,1505 +0,0 @@ -from django.conf import settings - -if getattr(settings, 'TESTING_PUBLISH', False): - import unittest - from django.test import TransactionTestCase - from django.contrib.admin.sites import AdminSite - from django.contrib.auth.models import User - from django.forms.models import ModelChoiceField, ModelMultipleChoiceField - from django.conf.urls.defaults import * - from django.core.exceptions import PermissionDenied - from django.http import Http404 - - from publish.models import Publishable, FlatPage, Site, Page, PageBlock, \ - Author, AuthorProfile, Tag, PageTagOrder, Comment, update_pub_date, \ - PublishException - - from publish.admin import PublishableAdmin, PublishableStackedInline - from publish.actions import publish_selected, delete_selected, \ - _convert_all_published_to_html, undelete_selected - from publish.utils import NestedSet - from publish.signals import pre_publish, post_publish - from publish.filters import PublishableRelatedFieldListFilter - - - def _get_rendered_content(response): - content = getattr(response, 'rendered_content', None) - if content is not None: - return content - return response.content - - - class TestNestedSet(unittest.TestCase): - - def setUp(self): - super(TestNestedSet, self).setUp() - self.nested = NestedSet() - - def test_len(self): - self.failUnlessEqual(0, len(self.nested)) - self.nested.add('one') - self.failUnlessEqual(1, len(self.nested)) - self.nested.add('two') - self.failUnlessEqual(2, len(self.nested)) - self.nested.add('one2', parent='one') - self.failUnlessEqual(3, len(self.nested)) - - def test_contains(self): - self.failIf('one' in self.nested) - self.nested.add('one') - self.failUnless('one' in self.nested) - self.nested.add('one2', parent='one') - self.failUnless('one2' in self.nested) - - def test_nested_items(self): - self.failUnlessEqual([], self.nested.nested_items()) - self.nested.add('one') - self.failUnlessEqual(['one'], self.nested.nested_items()) - self.nested.add('two') - self.nested.add('one2', parent='one') - self.failUnlessEqual(['one', ['one2'], 'two'], self.nested.nested_items()) - self.nested.add('one2-1', parent='one2') - self.nested.add('one2-2', parent='one2') - self.failUnlessEqual(['one', ['one2', ['one2-1', 'one2-2']], 'two'], self.nested.nested_items()) - - def test_iter(self): - self.failUnlessEqual(set(), set(self.nested)) - - self.nested.add('one') - self.failUnlessEqual(set(['one']), set(self.nested)) - - self.nested.add('two', parent='one') - self.failUnlessEqual(set(['one', 'two']), set(self.nested)) - - items = set(['one', 'two']) - - for item in self.nested: - self.failUnless(item in items) - items.remove(item) - - self.failUnlessEqual(set(), items) - - def test_original(self): - class MyObject(object): - def __init__(self, obj): - self.obj = obj - - def __eq__(self, other): - return self.obj == other.obj - - def __hash__(self): - return hash(self.obj) - - # should always return an item at least - self.failUnlessEqual(MyObject('hi there'), self.nested.original(MyObject('hi there'))) - - m1 = MyObject('m1') - self.nested.add(m1) - - self.failUnlessEqual(id(m1), id(self.nested.original(m1))) - self.failUnlessEqual(id(m1), id(self.nested.original(MyObject('m1')))) - - - - - class TestBasicPublishable(TransactionTestCase): - - def setUp(self): - super(TestBasicPublishable, self).setUp() - self.flat_page = FlatPage(url='/my-page', title='my page', - content='here is some content', - enable_comments=False, - registration_required=True) - - def test_get_public_absolute_url(self): - self.failUnlessEqual('/my-page*', self.flat_page.get_absolute_url()) - # public absolute url doesn't exist until published - self.assertTrue(self.flat_page.get_public_absolute_url() is None) - self.flat_page.save() - self.flat_page.publish() - self.failUnlessEqual('/my-page', self.flat_page.get_public_absolute_url()) - - def test_save_marks_changed(self): - self.failUnlessEqual(Publishable.PUBLISH_DEFAULT, self.flat_page.publish_state) - self.flat_page.save(mark_changed=False) - self.failUnlessEqual(Publishable.PUBLISH_DEFAULT, self.flat_page.publish_state) - self.flat_page.save() - self.failUnlessEqual(Publishable.PUBLISH_CHANGED, self.flat_page.publish_state) - - def test_publish_excludes_fields(self): - self.flat_page.save() - self.flat_page.publish() - self.failUnless(self.flat_page.public) - self.failIfEqual(self.flat_page.id, self.flat_page.public.id) - self.failUnless(self.flat_page.public.is_public) - self.failUnlessEqual(Publishable.PUBLISH_DEFAULT, self.flat_page.public.publish_state) - - def test_publish_check_is_not_public(self): - try: - self.flat_page.is_public = True - self.flat_page.publish() - self.fail("Should not be able to publish public models") - except PublishException: - pass - - def test_publish_check_has_id(self): - try: - self.flat_page.publish() - self.fail("Should not be able to publish unsaved models") - except PublishException: - pass - - def test_publish_simple_fields(self): - self.flat_page.save() - self.failUnlessEqual(Publishable.PUBLISH_CHANGED, self.flat_page.publish_state) - self.failIf(self.flat_page.public) # should not be a public version yet - - self.flat_page.publish() - self.failUnlessEqual(Publishable.PUBLISH_DEFAULT, self.flat_page.publish_state) - self.failUnless(self.flat_page.public) - - for field in 'url', 'title', 'content', 'enable_comments', 'registration_required': - self.failUnlessEqual(getattr(self.flat_page, field), getattr(self.flat_page.public, field)) - - def test_published_simple_field_repeated(self): - self.flat_page.save() - self.flat_page.publish() - - public = self.flat_page.public - self.failUnless(public) - - self.flat_page.title = 'New Title' - self.flat_page.save() - self.failUnlessEqual(Publishable.PUBLISH_CHANGED, self.flat_page.publish_state) - - self.failUnlessEqual(public, self.flat_page.public) - self.failIfEqual(public.title, self.flat_page.title) - - self.flat_page.publish() - self.failUnlessEqual(public, self.flat_page.public) - self.failUnlessEqual(public.title, self.flat_page.title) - self.failUnlessEqual(Publishable.PUBLISH_DEFAULT, self.flat_page.publish_state) - - def test_publish_records_published(self): - all_published = NestedSet() - self.flat_page.save() - self.flat_page.publish(all_published=all_published) - self.failUnlessEqual(1, len(all_published)) - self.failUnless(self.flat_page in all_published) - self.failUnless(self.flat_page.public) - - def test_publish_dryrun(self): - all_published = NestedSet() - self.flat_page.save() - self.flat_page.publish(dry_run=True, all_published=all_published) - self.failUnlessEqual(1, len(all_published)) - self.failUnless(self.flat_page in all_published) - self.failIf(self.flat_page.public) - self.failUnlessEqual(Publishable.PUBLISH_CHANGED, self.flat_page.publish_state) - - def test_delete_after_publish(self): - self.flat_page.save() - self.flat_page.publish() - public = self.flat_page.public - self.failUnless(public) - - self.flat_page.delete() - self.failUnlessEqual(Publishable.PUBLISH_DELETE, self.flat_page.publish_state) - - self.failUnlessEqual(set([self.flat_page, self.flat_page.public]), set(FlatPage.objects.all())) - - def test_delete_before_publish(self): - self.flat_page.save() - self.flat_page.delete() - self.failUnlessEqual([], list(FlatPage.objects.all())) - - def test_publish_deletions(self): - self.flat_page.save() - self.flat_page.publish() - public = self.flat_page.public - - self.failUnlessEqual(set([self.flat_page, public]), set(FlatPage.objects.all())) - - self.flat_page.delete() - self.failUnlessEqual(set([self.flat_page, public]), set(FlatPage.objects.all())) - - self.flat_page.publish() - self.failUnlessEqual([], list(FlatPage.objects.all())) - - def test_publish_deletions_checks_all_published(self): - # make sure publish_deletions looks at all_published arg - # to see if we need to actually publish the deletion - self.flat_page.save() - self.flat_page.publish() - public = self.flat_page.public - - self.flat_page.delete() - - self.failUnlessEqual(set([self.flat_page, public]), set(FlatPage.objects.all())) - - # this should effectively stop the deletion happening - all_published = NestedSet() - all_published.add(self.flat_page) - - self.flat_page.publish(all_published=all_published) - self.failUnlessEqual(set([self.flat_page, public]), set(FlatPage.objects.all())) - - - class TestPublishableManager(TransactionTestCase): - - def setUp(self): - super(TransactionTestCase, self).setUp() - self.flat_page1 = FlatPage.objects.create(url='/url1/', title='title 1') - self.flat_page2 = FlatPage.objects.create(url='/url2/', title='title 2') - - def test_all(self): - self.failUnlessEqual([self.flat_page1, self.flat_page2], list(FlatPage.objects.all())) - - # publishing will produce extra copies - self.flat_page1.publish() - self.failUnlessEqual(3, FlatPage.objects.count()) - - self.flat_page2.publish() - self.failUnlessEqual(4, FlatPage.objects.count()) - - - def test_changed(self): - self.failUnlessEqual([self.flat_page1, self.flat_page2], list(FlatPage.objects.changed())) - - self.flat_page1.publish() - self.failUnlessEqual([self.flat_page2], list(FlatPage.objects.changed())) - - self.flat_page2.publish() - self.failUnlessEqual([], list(FlatPage.objects.changed())) - - def test_draft(self): - # draft should stay the same pretty much always - self.failUnlessEqual([self.flat_page1, self.flat_page2], list(FlatPage.objects.draft())) - - self.flat_page1.publish() - self.failUnlessEqual([self.flat_page1, self.flat_page2], list(FlatPage.objects.draft())) - - self.flat_page2.publish() - self.failUnlessEqual([self.flat_page1, self.flat_page2], list(FlatPage.objects.draft())) - - self.flat_page2.delete() - self.failUnlessEqual([self.flat_page1], list(FlatPage.objects.draft())) - - - def test_published(self): - self.failUnlessEqual([], list(FlatPage.objects.published())) - - self.flat_page1.publish() - self.failUnlessEqual([self.flat_page1.public], list(FlatPage.objects.published())) - - self.flat_page2.publish() - self.failUnlessEqual([self.flat_page1.public, self.flat_page2.public], list(FlatPage.objects.published())) - - def test_deleted(self): - self.failUnlessEqual([], list(FlatPage.objects.deleted())) - - self.flat_page1.publish() - self.failUnlessEqual([], list(FlatPage.objects.deleted())) - - self.flat_page1.delete() - self.failUnlessEqual([self.flat_page1], list(FlatPage.objects.deleted())) - - def test_draft_and_deleted(self): - self.failUnlessEqual(set([self.flat_page1, self.flat_page2]), set(FlatPage.objects.draft_and_deleted())) - - self.flat_page1.publish() - self.failUnlessEqual(set([self.flat_page1, self.flat_page2]), set(FlatPage.objects.draft_and_deleted())) - self.failUnlessEqual(set([self.flat_page1, self.flat_page2]), set(FlatPage.objects.draft())) - - self.flat_page1.delete() - self.failUnlessEqual(set([self.flat_page1, self.flat_page2]), set(FlatPage.objects.draft_and_deleted())) - self.failUnlessEqual([self.flat_page2], list(FlatPage.objects.draft())) - - - def test_delete(self): - # delete is overriden, so it marks the public instances - self.flat_page1.publish() - public1 = self.flat_page1.public - - FlatPage.objects.draft().delete() - - self.failUnlessEqual([], list(FlatPage.objects.draft())) - self.failUnlessEqual([self.flat_page1], list(FlatPage.objects.deleted())) - self.failUnlessEqual([public1], list(FlatPage.objects.published())) - self.failUnlessEqual([self.flat_page1], list(FlatPage.objects.draft_and_deleted())) - - def test_publish(self): - self.failUnlessEqual([], list(FlatPage.objects.published())) - - FlatPage.objects.draft().publish() - - flat_page1 = FlatPage.objects.get(id=self.flat_page1.id) - flat_page2 = FlatPage.objects.get(id=self.flat_page2.id) - - self.failUnlessEqual(set([flat_page1.public, flat_page2.public]), set(FlatPage.objects.published())) - - - - class TestPublishableManyToMany(TransactionTestCase): - - def setUp(self): - super(TestPublishableManyToMany, self).setUp() - self.flat_page = FlatPage.objects.create( - url='/my-page', title='my page', - content='here is some content', - enable_comments=False, - registration_required=True) - self.site1 = Site.objects.create(title='my site', domain='mysite.com') - self.site2 = Site.objects.create(title='a site', domain='asite.com') - - def test_publish_no_sites(self): - self.flat_page.publish() - self.failUnless(self.flat_page.public) - self.failUnlessEqual([], list(self.flat_page.public.sites.all())) - - def test_publish_add_site(self): - self.flat_page.sites.add(self.site1) - self.flat_page.publish() - self.failUnless(self.flat_page.public) - self.failUnlessEqual([self.site1], list(self.flat_page.public.sites.all())) - - def test_publish_repeated_add_site(self): - self.flat_page.sites.add(self.site1) - self.flat_page.publish() - self.failUnless(self.flat_page.public) - self.failUnlessEqual([self.site1], list(self.flat_page.public.sites.all())) - - self.flat_page.sites.add(self.site2) - self.failUnlessEqual([self.site1], list(self.flat_page.public.sites.all())) - - self.flat_page.publish() - self.failUnlessEqual([self.site1, self.site2], list(self.flat_page.public.sites.order_by('id'))) - - def test_publish_remove_site(self): - self.flat_page.sites.add(self.site1, self.site2) - self.flat_page.publish() - self.failUnless(self.flat_page.public) - self.failUnlessEqual([self.site1, self.site2], list(self.flat_page.public.sites.order_by('id'))) - - self.flat_page.sites.remove(self.site1) - self.failUnlessEqual([self.site1, self.site2], list(self.flat_page.public.sites.order_by('id'))) - - self.flat_page.publish() - self.failUnlessEqual([self.site2], list(self.flat_page.public.sites.all())) - - def test_publish_clear_sites(self): - self.flat_page.sites.add(self.site1, self.site2) - self.flat_page.publish() - self.failUnless(self.flat_page.public) - self.failUnlessEqual([self.site1, self.site2], list(self.flat_page.public.sites.order_by('id'))) - - self.flat_page.sites.clear() - self.failUnlessEqual([self.site1, self.site2], list(self.flat_page.public.sites.order_by('id'))) - - self.flat_page.publish() - self.failUnlessEqual([], list(self.flat_page.public.sites.all())) - - def test_publish_sites_cleared_not_deleted(self): - self.flat_page.sites.add(self.site1, self.site2) - self.flat_page.publish() - self.flat_page.sites.clear() - self.flat_page.publish() - - self.failUnlessEqual([], list(self.flat_page.public.sites.all())) - - self.failIfEqual([], list(Site.objects.all())) - - - - - class TestPublishableRecursiveForeignKey(TransactionTestCase): - - def setUp(self): - super(TestPublishableRecursiveForeignKey, self).setUp() - self.page1 = Page.objects.create(slug='page1', title='page 1', content='some content') - self.page2 = Page.objects.create(slug='page2', title='page 2', content='other content', parent=self.page1) - - def test_publish_parent(self): - # this shouldn't publish the child page - self.page1.publish() - self.failUnless(self.page1.public) - self.failIf(self.page1.public.parent) - - page2 = Page.objects.get(id=self.page2.id) - self.failIf(page2.public) - - def test_publish_child_parent_already_published(self): - self.page1.publish() - self.page2.publish() - - self.failUnless(self.page1.public) - self.failUnless(self.page2.public) - - self.failIf(self.page1.public.parent) - self.failUnless(self.page2.public.parent) - - self.failIfEqual(self.page1, self.page2.public.parent) - - self.failUnlessEqual('/page1/', self.page1.public.get_absolute_url()) - self.failUnlessEqual('/page1/page2/', self.page2.public.get_absolute_url()) - - def test_publish_child_parent_not_already_published(self): - self.page2.publish() - - page1 = Page.objects.get(id=self.page1.id) - self.failUnless(page1.public) - self.failUnless(self.page2.public) - - self.failIf(page1.public.parent) - self.failUnless(self.page2.public.parent) - - self.failIfEqual(page1, self.page2.public.parent) - - self.failUnlessEqual('/page1/', self.page1.public.get_absolute_url()) - self.failUnlessEqual('/page1/page2/', self.page2.public.get_absolute_url()) - - def test_publish_repeated(self): - self.page1.publish() - self.page2.publish() - - self.page1.slug='main' - self.page1.save() - - self.failUnlessEqual('/main/', self.page1.get_absolute_url()) - - page1 = Page.objects.get(id=self.page1.id) - page2 = Page.objects.get(id=self.page2.id) - self.failUnlessEqual('/page1/', page1.public.get_absolute_url()) - self.failUnlessEqual('/page1/page2/', page2.public.get_absolute_url()) - - page1.publish() - page1 = Page.objects.get(id=self.page1.id) - page2 = Page.objects.get(id=self.page2.id) - self.failUnlessEqual('/main/', page1.public.get_absolute_url()) - self.failUnlessEqual('/main/page2/', page2.public.get_absolute_url()) - - page1.slug='elsewhere' - page1.save() - page1 = Page.objects.get(id=self.page1.id) - page2 = Page.objects.get(id=self.page2.id) - page2.slug='meanwhile' - page2.save() - page2.publish() - page1 = Page.objects.get(id=self.page1.id) - page2 = Page.objects.get(id=self.page2.id) - - # only page2 should be published, not page1, as page1 already published - self.failUnlessEqual(Publishable.PUBLISH_DEFAULT, page2.publish_state) - self.failUnlessEqual(Publishable.PUBLISH_CHANGED, page1.publish_state) - - self.failUnlessEqual('/main/', page1.public.get_absolute_url()) - self.failUnlessEqual('/main/meanwhile/', page2.public.get_absolute_url()) - - page1.publish() - page1 = Page.objects.get(id=self.page1.id) - page2 = Page.objects.get(id=self.page2.id) - - self.failUnlessEqual(Publishable.PUBLISH_DEFAULT, page2.publish_state) - self.failUnlessEqual(Publishable.PUBLISH_DEFAULT, page1.publish_state) - - self.failUnlessEqual('/elsewhere/', page1.public.get_absolute_url()) - self.failUnlessEqual('/elsewhere/meanwhile/', page2.public.get_absolute_url()) - - def test_publish_deletions(self): - self.page1.publish() - self.page2.publish() - - self.page2.delete() - self.failUnlessEqual([self.page2], list(Page.objects.deleted())) - - self.page2.publish() - self.failUnlessEqual([self.page1.public], list(Page.objects.published())) - self.failUnlessEqual([], list(Page.objects.deleted())) - - def test_publish_reverse_fields(self): - page_block = PageBlock.objects.create(page=self.page1, content='here we are') - - self.page1.publish() - - public = self.page1.public - self.failUnless(public) - - blocks = list(public.pageblock_set.all()) - self.failUnlessEqual(1, len(blocks)) - self.failUnlessEqual(page_block.content, blocks[0].content) - - def test_publish_deletions_reverse_fields(self): - page_block = PageBlock.objects.create(page=self.page1, content='here we are') - - self.page1.publish() - public = self.page1.public - self.failUnless(public) - - self.page1.delete() - - self.failUnlessEqual([self.page1], list(Page.objects.deleted())) - - self.page1.publish() - self.failUnlessEqual([], list(Page.objects.deleted())) - self.failUnlessEqual([], list(Page.objects.all())) - - def test_publish_reverse_fields_deleted(self): - # make sure child elements get removed - page_block = PageBlock.objects.create(page=self.page1, content='here we are') - - self.page1.publish() - - public = self.page1.public - page_block = PageBlock.objects.get(id=page_block.id) - page_block_public = page_block.public - self.failIf(page_block_public is None) - - self.failUnlessEqual([page_block_public], list(public.pageblock_set.all())) - - # now delete the page block and publish the parent - # to make sure that deletion gets copied over properly - page_block.delete() - page1 = Page.objects.get(id=self.page1.id) - page1.publish() - public = page1.public - - self.failUnlessEqual([], list(public.pageblock_set.all())) - - def test_publish_delections_with_non_publishable_children(self): - self.page1.publish() - - comment = Comment.objects.create(page=self.page1.public, comment='This is a comment') - - self.failUnlessEqual(1, Comment.objects.count()) - - self.page1.delete() - - self.failUnlessEqual([self.page1], list(Page.objects.deleted())) - self.failIf(self.page1 in Page.objects.draft()) - - self.page1.publish() - self.failUnlessEqual([], list(Page.objects.deleted())) - self.failUnlessEqual([], list(Page.objects.all())) - self.failUnlessEqual([], list(Comment.objects.all())) - - class TestPublishableRecursiveManyToManyField(TransactionTestCase): - - def setUp(self): - super(TestPublishableRecursiveManyToManyField, self).setUp() - self.page = Page.objects.create(slug='page1', title='page 1', content='some content') - self.author1 = Author.objects.create(name='author1', profile='a profile') - self.author2 = Author.objects.create(name='author2', profile='something else') - - def test_publish_add_author(self): - self.page.authors.add(self.author1) - self.page.publish() - self.failUnless(self.page.public) - - author1 = Author.objects.get(id=self.author1.id) - self.failUnless(author1.public) - self.failIfEqual(author1.id, author1.public.id) - self.failUnlessEqual(author1.name, author1.public.name) - self.failUnlessEqual(author1.profile, author1.public.profile) - - self.failUnlessEqual([author1.public], list(self.page.public.authors.all())) - - def test_publish_repeated_add_author(self): - self.page.authors.add(self.author1) - self.page.publish() - - self.failUnless(self.page.public) - - self.page.authors.add(self.author2) - author1 = Author.objects.get(id=self.author1.id) - self.failUnlessEqual([author1.public], list(self.page.public.authors.all())) - - self.page.publish() - author1 = Author.objects.get(id=self.author1.id) - author2 = Author.objects.get(id=self.author2.id) - self.failUnlessEqual([author1.public, author2.public], list(self.page.public.authors.order_by('name'))) - - def test_publish_clear_authors(self): - self.page.authors.add(self.author1, self.author2) - self.page.publish() - - author1 = Author.objects.get(id=self.author1.id) - author2 = Author.objects.get(id=self.author2.id) - self.failUnlessEqual([author1.public, author2.public], list(self.page.public.authors.order_by('name'))) - - self.page.authors.clear() - self.failUnlessEqual([author1.public, author2.public], list(self.page.public.authors.order_by('name'))) - - self.page.publish() - self.failUnlessEqual([], list(self.page.public.authors.all())) - - class TestInfiniteRecursion(TransactionTestCase): - - def setUp(self): - super(TestInfiniteRecursion, self).setUp() - - self.page1 = Page.objects.create(slug='page1', title='page 1') - self.page2 = Page.objects.create(slug='page2', title='page 2', parent=self.page1) - self.page1.parent = self.page2 - self.page1.save() - - def test_publish_recursion_breaks(self): - self.page1.publish() # this should simple run without an error - - class TestOverlappingPublish(TransactionTestCase): - - def setUp(self): - self.page1 = Page.objects.create(slug='page1', title='page 1') - self.page2 = Page.objects.create(slug='page2', title='page 2') - self.child1 = Page.objects.create(parent=self.page1, slug='child1', title='Child 1') - self.child2 = Page.objects.create(parent=self.page1, slug='child2', title='Child 2') - self.child3 = Page.objects.create(parent=self.page2, slug='child3', title='Child 3') - - def test_publish_with_overlapping_models(self): - # make sure when we publish we don't accidentally create - # multiple published versions - self.failUnlessEqual(5, Page.objects.draft().count()) - self.failUnlessEqual(0, Page.objects.published().count()) - - Page.objects.draft().publish() - - self.failUnlessEqual(5, Page.objects.draft().count()) - self.failUnlessEqual(5, Page.objects.published().count()) - - def test_publish_with_overlapping_models_published(self): - # make sure when we publish we don't accidentally create - # multiple published versions - self.failUnlessEqual(5, Page.objects.draft().count()) - self.failUnlessEqual(0, Page.objects.published().count()) - - all_published = NestedSet() - Page.objects.draft().publish(all_published) - - self.failUnlessEqual(5, len(all_published)) - - self.failUnlessEqual(5, Page.objects.draft().count()) - self.failUnlessEqual(5, Page.objects.published().count()) - - def test_publish_after_dry_run_handles_caching(self): - # if we do a dry tun publish in the same queryset - # before publishing for real, we have to make - # sure we don't run into issues with the instance - # caching parent's as None - self.failUnlessEqual(5, Page.objects.draft().count()) - self.failUnlessEqual(0, Page.objects.published().count()) - - draft = Page.objects.draft() - - all_published = NestedSet() - for p in draft: - p.publish(dry_run=True, all_published=all_published) - - # nothing published yet - self.failUnlessEqual(5, Page.objects.draft().count()) - self.failUnlessEqual(0, Page.objects.published().count()) - - # now publish (using same queryset, as this will have cached the instances) - draft.publish() - - self.failUnlessEqual(5, Page.objects.draft().count()) - self.failUnlessEqual(5, Page.objects.published().count()) - - # now actually check the public parent's are setup right - page1 = Page.objects.get(id=self.page1.id) - page2 = Page.objects.get(id=self.page2.id) - child1 = Page.objects.get(id=self.child1.id) - child2 = Page.objects.get(id=self.child2.id) - child3 = Page.objects.get(id=self.child3.id) - - self.failUnlessEqual(None, page1.public.parent) - self.failUnlessEqual(None, page2.public.parent) - self.failUnlessEqual(page1.public, child1.public.parent) - self.failUnlessEqual(page1.public, child2.public.parent) - self.failUnlessEqual(page2.public, child3.public.parent) - - class TestPublishableAdmin(TransactionTestCase): - - def setUp(self): - super(TestPublishableAdmin, self).setUp() - self.page1 = Page.objects.create(slug='page1', title='page 1') - self.page2 = Page.objects.create(slug='page2', title='page 2') - self.page1.publish() - self.page2.publish() - - self.author1 = Author.objects.create(name='a1') - self.author2 = Author.objects.create(name='a2') - self.author1.publish() - self.author2.publish() - - self.admin_site = AdminSite('Test Admin') - - class PageBlockInline(PublishableStackedInline): - model = PageBlock - - class PageAdmin(PublishableAdmin): - inlines = [PageBlockInline] - - self.admin_site.register(Page, PageAdmin) - self.page_admin = PageAdmin(Page, self.admin_site) - - # override urls, so reverse works - settings.ROOT_URLCONF=patterns('', - ('^admin/', include(self.admin_site.urls)), - ) - - def test_get_publish_status_display(self): - page = Page.objects.create(slug="hhkkk", title="hjkhjkh") - self.failUnlessEqual('Changed - not yet published', self.page_admin.get_publish_status_display(page)) - page.publish() - self.failUnlessEqual('Published', self.page_admin.get_publish_status_display(page)) - page.save() - self.failUnlessEqual('Changed', self.page_admin.get_publish_status_display(page)) - - page.delete() - self.failUnlessEqual('To be deleted', self.page_admin.get_publish_status_display(page)) - - def test_queryset(self): - # make sure we only get back draft objects - request = None - - self.failUnlessEqual( - set([self.page1, self.page1.public, self.page2, self.page2.public]), - set(Page.objects.all()) - ) - self.failUnlessEqual( - set([self.page1, self.page2]), - set(self.page_admin.queryset(request)) - ) - - def test_get_actions_global_delete_replaced(self): - from publish.actions import delete_selected - - class request(object): - GET = {} - - actions = self.page_admin.get_actions(request) - - - self.failUnless('delete_selected' in actions) - action, name, description = actions['delete_selected'] - self.failUnlessEqual(delete_selected, action) - self.failUnlessEqual('delete_selected', name) - self.failUnlessEqual(delete_selected.short_description, description) - - def test_formfield_for_foreignkey(self): - # foreign key forms fields in admin - # for publishable models should be filtered - # to hide public object - - request = None - parent_field = None - for field in Page._meta.fields: - if field.name == 'parent': - parent_field = field - break - self.failUnless(parent_field) - - choice_field = self.page_admin.formfield_for_foreignkey(parent_field, request) - self.failUnless(choice_field) - self.failUnless(isinstance(choice_field, ModelChoiceField)) - - self.failUnlessEqual( - set([self.page1, self.page1.public, self.page2, self.page2.public]), - set(Page.objects.all()) - ) - self.failUnlessEqual( - set([self.page1, self.page2]), - set(choice_field.queryset) - ) - - def test_formfield_for_manytomany(self): - request = None - authors_field = None - for field in Page._meta.many_to_many: - if field.name == 'authors': - authors_field = field - break - self.failUnless(authors_field) - - choice_field = self.page_admin.formfield_for_manytomany(authors_field, request) - self.failUnless(choice_field) - self.failUnless(isinstance(choice_field, ModelMultipleChoiceField)) - - self.failUnlessEqual( - set([self.author1, self.author1.public, self.author2, self.author2.public]), - set(Author.objects.all()) - ) - self.failUnlessEqual( - set([self.author1, self.author2]), - set(choice_field.queryset) - ) - - def test_has_change_permission(self): - class dummy_request(object): - method = 'GET' - REQUEST = {} - - class user(object): - @classmethod - def has_perm(cls, permission): - return True - - self.failUnless(self.page_admin.has_change_permission(dummy_request)) - self.failUnless(self.page_admin.has_change_permission(dummy_request, self.page1)) - self.failIf(self.page_admin.has_change_permission(dummy_request, self.page1.public)) - - # can view deleted items - self.page1.publish_state = Publishable.PUBLISH_DELETE - self.failUnless(self.page_admin.has_change_permission(dummy_request, self.page1)) - - # but cannot modify them - dummy_request.method = 'POST' - self.failIf(self.page_admin.has_change_permission(dummy_request, self.page1)) - - def test_has_delete_permission(self): - class dummy_request(object): - method = 'GET' - REQUEST = {} - - class user(object): - @classmethod - def has_perm(cls, permission): - return True - - self.failUnless(self.page_admin.has_delete_permission(dummy_request)) - self.failUnless(self.page_admin.has_delete_permission(dummy_request, self.page1)) - self.failIf(self.page_admin.has_delete_permission(dummy_request, self.page1.public)) - - def test_change_view_normal(self): - class dummy_request(object): - method = 'GET' - REQUEST = {} - COOKIES = {} - META = {} - - @classmethod - def is_ajax(cls): - return False - - @classmethod - def is_secure(cls): - return False - - class user(object): - @classmethod - def has_perm(cls, permission): - return True - - @classmethod - def get_and_delete_messages(cls): - return [] - - response = self.page_admin.change_view(dummy_request, str(self.page1.id)) - self.failUnless(response is not None) - self.failIf('deleted' in _get_rendered_content(response)) - - def test_change_view_not_deleted(self): - class dummy_request(object): - method = 'GET' - COOKIES = {} - META = {} - - @classmethod - def is_ajax(cls): - return False - - @classmethod - def is_secure(cls): - return False - - class user(object): - @classmethod - def has_perm(cls, permission): - return True - - try: - self.page_admin.change_view(dummy_request, unicode(self.page1.public.id)) - self.fail() - except Http404: - pass - - def test_change_view_deleted(self): - class dummy_request(object): - method = 'GET' - REQUEST = {} - COOKIES = {} - META = {} - - @classmethod - def is_ajax(cls): - return False - - @classmethod - def is_secure(cls): - return False - - class user(object): - @classmethod - def has_perm(cls, permission): - return True - - @classmethod - def get_and_delete_messages(cls): - return [] - - self.page1.delete() - - response = self.page_admin.change_view(dummy_request, str(self.page1.id)) - self.failUnless(response is not None) - self.failUnless('deleted' in _get_rendered_content(response)) - - def test_change_view_deleted_POST(self): - class dummy_request(object): - csrf_processing_done = True # stop csrf check - method = 'POST' - COOKIES = {} - META = {} - - @classmethod - def is_ajax(cls): - return False - - @classmethod - def is_secure(cls): - return False - - self.page1.delete() - - try: - self.page_admin.change_view(dummy_request, str(self.page1.id)) - self.fail() - except PermissionDenied: - pass - - def test_change_view_delete_inline(self): - block = PageBlock.objects.create(page=self.page1, content='some content') - page1 = Page.objects.get(pk=self.page1.pk) - page1.publish() - - user1 = User.objects.create_user('test1', 'test@example.com', 'jkljkl') - - # fake selecting the delete tickbox for the block - - class dummy_request(object): - csrf_processing_done = True - method = 'POST' - - POST = { - 'slug': page1.slug, - 'title': page1.title, - 'content': page1.content, - 'pub_date_0': '2010-02-12', - 'pub_date_1': '17:40:00', - 'pageblock_set-TOTAL_FORMS': '2', - 'pageblock_set-INITIAL_FORMS': '1', - 'pageblock_set-0-id': str(block.id), - 'pageblock_set-0-page': str(page1.id), - 'pageblock_set-0-DELETE': 'yes' - } - REQUEST = POST - FILES = {} - COOKIES = {} - META = {} - - @classmethod - def is_ajax(cls): - return False - - @classmethod - def is_secure(cls): - return False - - class user(object): - pk = user1.pk - - @classmethod - def is_authenticated(self): - return True - - @classmethod - def has_perm(cls, permission): - return True - - @classmethod - def get_and_delete_messages(cls): - return [] - - class message_set(object): - @classmethod - def create(cls, message=''): - pass - - class _messages(object): - @classmethod - def add(cls, *message): - pass - - - block = PageBlock.objects.get(id=block.id) - public_block = block.public - - response = self.page_admin.change_view(dummy_request, str(page1.id)) - self.assertEqual(302, response.status_code) - - # the block should have been deleted (but not the public one) - self.failUnlessEqual([public_block], list(PageBlock.objects.all())) - - - class TestPublishSelectedAction(TransactionTestCase): - - def setUp(self): - super(TestPublishSelectedAction, self).setUp() - self.fp1 = Page.objects.create(slug='fp1', title='FP1') - self.fp2 = Page.objects.create(slug='fp2', title='FP2') - self.fp3 = Page.objects.create(slug='fp3', title='FP3') - - self.admin_site = AdminSite('Test Admin') - self.page_admin = PublishableAdmin(Page, self.admin_site) - - # override urls, so reverse works - settings.ROOT_URLCONF=patterns('', - ('^admin/', include(self.admin_site.urls)), - ) - - def test_publish_selected_confirm(self): - pages = Page.objects.exclude(id=self.fp3.id) - - class dummy_request(object): - META = {} - POST = {} - - class user(object): - @classmethod - def has_perm(cls, *arg): - return True - - @classmethod - def get_and_delete_messages(cls): - return [] - - response = publish_selected(self.page_admin, dummy_request, pages) - - self.failIf(Page.objects.published().count() > 0) - self.failUnless(response is not None) - self.failUnlessEqual(200, response.status_code) - - def test_publish_selected_confirmed(self): - pages = Page.objects.exclude(id=self.fp3.id) - - class dummy_request(object): - POST = {'post': True} - - class user(object): - @classmethod - def is_authenticated(cls): - return True - - @classmethod - def has_perm(cls, *arg): - return True - - class message_set(object): - @classmethod - def create(cls, message=None): - self._message = message - - class _messages(object): - @classmethod - def add(cls, *message): - self._message = message - - - response = publish_selected(self.page_admin, dummy_request, pages) - - - self.failUnlessEqual(2, Page.objects.published().count()) - self.failUnless( getattr(self, '_message', None) is not None ) - self.failUnless( response is None ) - - def test_convert_all_published_to_html(self): - self.admin_site.register(Page, PublishableAdmin) - - all_published = NestedSet() - - page = Page.objects.create(slug='here', title='title') - block = PageBlock.objects.create(page=page, content='stuff here') - - all_published.add(page) - all_published.add(block, parent=page) - - converted = _convert_all_published_to_html(self.admin_site, all_published) - - expected = [u'Page: Page object (Changed - not yet published)' % page.id, [u'Page block: PageBlock object']] - - self.failUnlessEqual(expected, converted) - - def test_publish_selected_does_not_have_permission(self): - self.admin_site.register(Page, PublishableAdmin) - pages = Page.objects.exclude(id=self.fp3.id) - - class dummy_request(object): - POST = {} - - class user(object): - @classmethod - def has_perm(cls, *arg): - return False - - @classmethod - def get_and_delete_messages(cls): - return [] - - response = publish_selected(self.page_admin, dummy_request, pages) - self.failIf(response is None) - # publish button should not be in response - self.failIf('value="publish_selected"' in response.content) - self.failIf('value="Yes, Publish"' in response.content) - self.failIf('form' in response.content) - - self.failIf(Page.objects.published().count() > 0) - - def test_publish_selected_does_not_have_related_permission(self): - # check we can't publish when we don't have permission - # for a related model (in this case authors) - self.admin_site.register(Author, PublishableAdmin) - - author = Author.objects.create(name='John') - self.fp1.authors.add(author) - - pages = Page.objects.draft() - - class dummy_request(object): - POST = { 'post': True } - - class user(object): - pk = 1 - - @classmethod - def is_authenticated(cls): - return True - - @classmethod - def has_perm(cls, perm): - return perm != 'publish.publish_author' - - try: - publish_selected(self.page_admin, dummy_request, pages) - self.fail() - except PermissionDenied: - pass - - self.failIf(Page.objects.published().count() > 0) - - def test_publish_selected_logs_publication(self): - self.admin_site.register(Page, PublishableAdmin) - - pages = Page.objects.exclude(id=self.fp3.id) - - class dummy_request(object): - POST = { 'post': True } - - class user(object): - pk = 1 - - @classmethod - def is_authenticated(cls): - return True - - @classmethod - def has_perm(cls, perm): - return perm != 'publish.publish_author' - - class message_set(object): - @classmethod - def create(cls, message=None): - pass - - class _messages(object): - @classmethod - def add(cls, *message): - pass - - publish_selected(self.page_admin, dummy_request, pages) - - # should have logged two publications - from django.contrib.admin.models import LogEntry - from django.contrib.contenttypes.models import ContentType - - content_type_id = ContentType.objects.get_for_model(self.fp1).pk - self.failUnlessEqual(2, LogEntry.objects.filter().count()) - - - class TestDeleteSelected(TransactionTestCase): - - def setUp(self): - super(TestDeleteSelected, self).setUp() - self.fp1 = FlatPage.objects.create(url='/fp1', title='FP1') - self.fp2 = FlatPage.objects.create(url='/fp2', title='FP2') - self.fp3 = FlatPage.objects.create(url='/fp3', title='FP3') - - self.fp1.publish() - self.fp2.publish() - self.fp3.publish() - - self.admin_site = AdminSite('Test Admin') - self.page_admin = PublishableAdmin(FlatPage, self.admin_site) - - # override urls, so reverse works - settings.ROOT_URLCONF=patterns('', - ('^admin/', include(self.admin_site.urls)), - ) - - def test_delete_selected_check_cannot_delete_public(self): - # delete won't work (via admin) for public instances - request = None - try: - delete_selected(self.page_admin, request, FlatPage.objects.published()) - fail() - except PermissionDenied: - pass - - def test_delete_selected(self): - class dummy_request(object): - POST = {} - META = {} - - class user(object): - @classmethod - def has_perm(cls, *arg): - return True - - @classmethod - def get_and_delete_messages(cls): - return [] - - response = delete_selected(self.page_admin, dummy_request, FlatPage.objects.draft()) - self.failUnless(response is not None) - - class TestUndeleteSelected(TransactionTestCase): - - def setUp(self): - super(TestUndeleteSelected, self).setUp() - self.fp1 = FlatPage.objects.create(url='/fp1', title='FP1') - - self.fp1.publish() - - self.admin_site = AdminSite('Test Admin') - self.page_admin = PublishableAdmin(FlatPage, self.admin_site) - - def test_undelete_selected(self): - class dummy_request(object): - - class user(object): - @classmethod - def has_perm(cls, *arg): - return True - - self.fp1.delete() - self.failUnlessEqual(Publishable.PUBLISH_DELETE, self.fp1.publish_state) - - response = undelete_selected(self.page_admin, dummy_request, FlatPage.objects.deleted()) - self.failUnless(response is None) - - # publish state should no longer be delete - fp1 = FlatPage.objects.get(pk=self.fp1.pk) - self.failUnlessEqual(Publishable.PUBLISH_CHANGED, fp1.publish_state) - - def test_undelete_selected_no_permission(self): - class dummy_request(object): - - class user(object): - @classmethod - def has_perm(cls, *arg): - return False - - self.fp1.delete() - self.failUnlessEqual(Publishable.PUBLISH_DELETE, self.fp1.publish_state) - - try: - undelete_selected(self.page_admin, dummy_request, FlatPage.objects.deleted()) - fail() - except PermissionDenied: - pass - - class TestManyToManyThrough(TransactionTestCase): - - def setUp(self): - super(TestManyToManyThrough, self).setUp() - self.page = Page.objects.create(slug='p1', title='P 1') - self.tag1 = Tag.objects.create(slug='tag1', title='Tag 1') - self.tag2 = Tag.objects.create(slug='tag2', title='Tag 2') - PageTagOrder.objects.create(tagged_page=self.page, page_tag=self.tag1, tag_order=2) - PageTagOrder.objects.create(tagged_page=self.page, page_tag=self.tag2, tag_order=1) - - def test_publish_copies_tags(self): - self.page.publish() - - self.failUnlessEqual(set([self.tag1, self.tag2]), set(self.page.public.tags.all())) - - class TestPublishFunction(TransactionTestCase): - - def setUp(self): - super(TestPublishFunction, self).setUp() - self.page = Page.objects.create(slug='page', title='Page') - - def test_publish_function_invoked(self): - # check we can override default copy behaviour - - from datetime import datetime - - pub_date = datetime(2000, 1, 1) - update_pub_date.pub_date = pub_date - - self.failIfEqual(pub_date, self.page.pub_date) - - self.page.publish() - self.failIfEqual(pub_date, self.page.pub_date) - self.failUnlessEqual(pub_date, self.page.public.pub_date) - - - class TestPublishSignals(TransactionTestCase): - - def setUp(self): - self.page1 = Page.objects.create(slug='page1', title='page 1') - self.page2 = Page.objects.create(slug='page2', title='page 2') - self.child1 = Page.objects.create(parent=self.page1, slug='child1', title='Child 1') - self.child2 = Page.objects.create(parent=self.page1, slug='child2', title='Child 2') - self.child3 = Page.objects.create(parent=self.page2, slug='child3', title='Child 3') - - self.failUnlessEqual(5, Page.objects.draft().count()) - - def _check_pre_publish(self, queryset): - pre_published = [] - def pre_publish_handler(sender, instance, **kw): - pre_published.append(instance) - - pre_publish.connect(pre_publish_handler, sender=Page) - - queryset.draft().publish() - - self.failUnlessEqual(queryset.draft().count(), len(pre_published)) - self.failUnlessEqual(set(queryset.draft()), set(pre_published)) - - def test_pre_publish(self): - # page order shouldn't matter when publishing - # should always get the right number of signals - self._check_pre_publish(Page.objects.order_by('id')) - self._check_pre_publish(Page.objects.order_by('-id')) - self._check_pre_publish(Page.objects.order_by('?')) - - def _check_post_publish(self, queryset): - published = [] - def post_publish_handler(sender, instance, **kw): - published.append(instance) - - post_publish.connect(post_publish_handler, sender=Page) - - queryset.draft().publish() - - self.failUnlessEqual(queryset.draft().count(), len(published)) - self.failUnlessEqual(set(queryset.draft()), set(published)) - - def test_post_publish(self): - self._check_post_publish(Page.objects.order_by('id')) - self._check_post_publish(Page.objects.order_by('-id')) - self._check_post_publish(Page.objects.order_by('?')) - - def test_signals_sent_for_followed(self): - pre_published = [] - def pre_publish_handler(sender, instance, **kw): - pre_published.append(instance) - - pre_publish.connect(pre_publish_handler, sender=Page) - - published = [] - def post_publish_handler(sender, instance, **kw): - published.append(instance) - - post_publish.connect(post_publish_handler, sender=Page) - - # publishing just children will also publish it's parent (if needed) - # which should also fire signals - - self.child1.publish() - - self.failUnlessEqual(set([self.page1, self.child1]), set(pre_published)) - self.failUnlessEqual(set([self.page1, self.child1]), set(published)) - - def test_deleted_flag_false_when_publishing_change(self): - def pre_publish_handler(sender, instance, deleted, **kw): - self.failIf(deleted) - - pre_publish.connect(pre_publish_handler, sender=Page) - - def post_publish_handler(sender, instance, deleted, **kw): - self.failIf(deleted) - - post_publish.connect(post_publish_handler, sender=Page) - - self.page1.publish() - - def test_deleted_flag_true_when_publishing_deletion(self): - self.child1.publish() - public = self.child1.public - - self.child1.delete() - - self.failUnlessEqual(Publishable.PUBLISH_DELETE, self.child1.publish_state) - - def pre_publish_handler(sender, instance, deleted, **kw): - self.failUnless(deleted) - - pre_publish.connect(pre_publish_handler, sender=Page) - - def post_publish_handler(sender, instance, deleted, **kw): - self.failUnless(deleted) - - post_publish.connect(post_publish_handler, sender=Page) - - self.child1.publish() - - - try: - from django.contrib.admin.filters import FieldListFilter - except ImportError: - # pre 1.4 - from django.contrib.admin.filterspecs import FilterSpec - class FieldListFilter(object): - @classmethod - def create(cls, field, request, params, model, model_admin, *arg, **kw): - return FilterSpec.create(field, request, params, model, model_admin) - - - class TestPublishableRelatedFilterSpec(TransactionTestCase): - - def test_overridden_spec(self): - # make sure the publishable filter spec - # gets used when we use a publishable field - class dummy_request(object): - GET = {} - - spec = FieldListFilter.create(Page._meta.get_field('authors'), dummy_request, {}, Page, PublishableAdmin, None) - self.failUnless(isinstance(spec, PublishableRelatedFieldListFilter)) - - def test_only_draft_shown(self): - self.author = Author.objects.create(name='author') - self.author.publish() - - self.failUnless(2, Author.objects.count()) - - # make sure the publishable filter spec - # gets used when we use a publishable field - class dummy_request(object): - GET = {} - - spec = FieldListFilter.create(Page._meta.get_field('authors'), dummy_request, {}, Page, PublishableAdmin, None) - - lookup_choices = spec.lookup_choices - self.failUnlessEqual(1, len(lookup_choices)) - pk, label = lookup_choices[0] - self.failUnlessEqual(self.author.id, pk) - diff --git a/publish/tests/__init__.py b/publish/tests/__init__.py new file mode 100644 index 0000000..6829615 --- /dev/null +++ b/publish/tests/__init__.py @@ -0,0 +1,7 @@ +from django.conf import settings +from publish.tests import settings_for_test + +settings.configure(settings_for_test) + +from django.core.management import call_command +call_command('syncdb', interactive=False) diff --git a/publish/tests/example_app/__init__.py b/publish/tests/example_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/publish/tests/example_app/models.py b/publish/tests/example_app/models.py new file mode 100644 index 0000000..78873da --- /dev/null +++ b/publish/tests/example_app/models.py @@ -0,0 +1,111 @@ +from datetime import datetime +from django.db import models +from publish.models import Publishable + + +class Site(models.Model): + title = models.CharField(max_length=100) + domain = models.CharField(max_length=100) + + +class FlatPage(Publishable): + url = models.CharField(max_length=100, db_index=True) + title = models.CharField(max_length=200) + content = models.TextField(blank=True) + enable_comments = models.BooleanField() + template_name = models.CharField(max_length=70, blank=True) + registration_required = models.BooleanField() + sites = models.ManyToManyField(Site) + + class Meta: + ordering = ['url'] + + def get_absolute_url(self): + if self.is_public: + return self.url + return '%s*' % self.url + + def __unicode__(self): + return "{0} - {1}".format(self.url, self.get_publish_state_display()) + + +class Author(Publishable): + name = models.CharField(max_length=100) + profile = models.TextField(blank=True) + + class PublishMeta(Publishable.PublishMeta): + publish_reverse_fields = ['authorprofile'] + + def __unicode__(self): + return self.name + + +class AuthorProfile(Publishable): + author = models.OneToOneField(Author) + extra_profile = models.TextField(blank=True) + + +class ChangeLog(models.Model): + changed = models.DateTimeField(db_index=True, auto_now_add=True) + message = models.CharField(max_length=200) + + +class Tag(models.Model): + title = models.CharField(max_length=100, unique=True) + slug = models.CharField(max_length=100) + + +# publishable model with a reverse relation to +# page (as a child) +class PageBlock(Publishable): + page = models.ForeignKey('Page') + content = models.TextField(blank=True) + + +# non-publishable reverse relation to page (as a child) +class Comment(models.Model): + page = models.ForeignKey('Page') + comment = models.TextField() + + +def update_pub_date(page, field_name, value): + # ignore value entirely and replace with now + setattr(page, field_name, update_pub_date.pub_date) +update_pub_date.pub_date = datetime.now() + + +class Page(Publishable): + slug = models.CharField(max_length=100, db_index=True) + title = models.CharField(max_length=200) + content = models.TextField(blank=True) + pub_date = models.DateTimeField(default=datetime.now) + + parent = models.ForeignKey('self', blank=True, null=True) + + authors = models.ManyToManyField(Author, blank=True) + log = models.ManyToManyField(ChangeLog, blank=True) + tags = models.ManyToManyField(Tag, through='PageTagOrder', blank=True) + + class Meta: + ordering = ['slug'] + + class PublishMeta(Publishable.PublishMeta): + publish_exclude_fields = ['log'] + publish_reverse_fields = ['pageblock_set'] + publish_functions = {'pub_date': update_pub_date} + + def get_absolute_url(self): + if not self.parent: + return u'/%s/' % self.slug + return '%s%s/' % (self.parent.get_absolute_url(), self.slug) + + def __unicode__(self): + return self.slug + + +class PageTagOrder(Publishable): + # note these are named in non-standard way to + # ensure we are getting correct names + tagged_page = models.ForeignKey(Page) + page_tag = models.ForeignKey(Tag) + tag_order = models.IntegerField() diff --git a/publish/tests/example_app/views.py b/publish/tests/example_app/views.py new file mode 100644 index 0000000..60f00ef --- /dev/null +++ b/publish/tests/example_app/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/publish/tests/helpers.py b/publish/tests/helpers.py new file mode 100644 index 0000000..736389c --- /dev/null +++ b/publish/tests/helpers.py @@ -0,0 +1,5 @@ +def _get_rendered_content(response): + content = getattr(response, 'rendered_content', None) + if content is not None: + return content + return response.content diff --git a/publish/tests/settings_for_test.py b/publish/tests/settings_for_test.py new file mode 100644 index 0000000..27660c8 --- /dev/null +++ b/publish/tests/settings_for_test.py @@ -0,0 +1,41 @@ +import logging +from django.conf.global_settings import * + +DEBUG = True +TEMPLATE_DEBUG = DEBUG + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': 'test.db', + 'USER': '', + 'PASSWORD': '', + } +} +INSTALLED_APPS = ( + 'django.contrib.contenttypes', + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.messages', + 'publish', + 'publish.tests.example_app', + 'django_nose', + +) + +TEMPLATE_LOADERS = ( + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', +) +MIDDLEWARE_CLASSES = ( + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', +) + + +TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' + +logging.disable(logging.CRITICAL) diff --git a/publish/tests/test_basic_publishable.py b/publish/tests/test_basic_publishable.py new file mode 100644 index 0000000..0546e67 --- /dev/null +++ b/publish/tests/test_basic_publishable.py @@ -0,0 +1,165 @@ +from django.test import TestCase +from publish.models import Publishable, PublishException +from publish.tests.example_app.models import FlatPage +from publish.utils import NestedSet + + +class TestBasicPublishable(TestCase): + def setUp(self): + super(TestBasicPublishable, self).setUp() + self.flat_page = FlatPage(url='/my-page', title='my page', + content='here is some content', + enable_comments=False, + registration_required=True) + + def test_get_public_absolute_url(self): + self.failUnlessEqual('/my-page*', self.flat_page.get_absolute_url()) + # public absolute url doesn't exist until published + self.assertTrue(self.flat_page.get_public_absolute_url() is None) + self.flat_page.save() + self.flat_page.publish() + self.failUnlessEqual('/my-page', + self.flat_page.get_public_absolute_url()) + + def test_save_marks_changed(self): + self.failUnlessEqual(Publishable.PUBLISH_DEFAULT, + self.flat_page.publish_state) + self.flat_page.save(mark_changed=False) + self.failUnlessEqual(Publishable.PUBLISH_DEFAULT, + self.flat_page.publish_state) + self.flat_page.save() + self.failUnlessEqual(Publishable.PUBLISH_CHANGED, + self.flat_page.publish_state) + + def test_publish_excludes_fields(self): + self.flat_page.save() + self.flat_page.publish() + self.failUnless(self.flat_page.public) + self.failIfEqual(self.flat_page.id, self.flat_page.public.id) + self.failUnless(self.flat_page.public.is_public) + self.failUnlessEqual(Publishable.PUBLISH_DEFAULT, + self.flat_page.public.publish_state) + + def test_publish_check_is_not_public(self): + try: + self.flat_page.is_public = True + self.flat_page.publish() + self.fail("Should not be able to publish public models") + except PublishException: + pass + + def test_publish_check_has_id(self): + try: + self.flat_page.publish() + self.fail("Should not be able to publish unsaved models") + except PublishException: + pass + + def test_publish_simple_fields(self): + self.flat_page.save() + self.failUnlessEqual(Publishable.PUBLISH_CHANGED, + self.flat_page.publish_state) + # should not be a public version yet + self.failIf(self.flat_page.public) + + self.flat_page.publish() + self.failUnlessEqual(Publishable.PUBLISH_DEFAULT, + self.flat_page.publish_state) + self.failUnless(self.flat_page.public) + + for field in 'url', 'title', 'content', 'enable_comments', \ + 'registration_required': + self.failUnlessEqual(getattr(self.flat_page, field), + getattr(self.flat_page.public, field)) + + def test_published_simple_field_repeated(self): + self.flat_page.save() + self.flat_page.publish() + + public = self.flat_page.public + self.failUnless(public) + + self.flat_page.title = 'New Title' + self.flat_page.save() + self.failUnlessEqual(Publishable.PUBLISH_CHANGED, + self.flat_page.publish_state) + + self.failUnlessEqual(public, self.flat_page.public) + self.failIfEqual(public.title, self.flat_page.title) + + self.flat_page.publish() + self.failUnlessEqual(public, self.flat_page.public) + self.failUnlessEqual(public.title, self.flat_page.title) + self.failUnlessEqual(Publishable.PUBLISH_DEFAULT, + self.flat_page.publish_state) + + def test_publish_records_published(self): + all_published = NestedSet() + self.flat_page.save() + self.flat_page.publish(all_published=all_published) + self.failUnlessEqual(1, len(all_published)) + self.failUnless(self.flat_page in all_published) + self.failUnless(self.flat_page.public) + + def test_publish_dryrun(self): + all_published = NestedSet() + self.flat_page.save() + self.flat_page.publish(dry_run=True, all_published=all_published) + self.failUnlessEqual(1, len(all_published)) + self.failUnless(self.flat_page in all_published) + self.failIf(self.flat_page.public) + self.failUnlessEqual(Publishable.PUBLISH_CHANGED, + self.flat_page.publish_state) + + def test_delete_after_publish(self): + self.flat_page.save() + self.flat_page.publish() + public = self.flat_page.public + self.failUnless(public) + + self.flat_page.delete() + self.failUnlessEqual(Publishable.PUBLISH_DELETE, + self.flat_page.publish_state) + + self.failUnlessEqual(set([self.flat_page, self.flat_page.public]), + set(FlatPage.objects.all())) + + def test_delete_before_publish(self): + self.flat_page.save() + self.flat_page.delete() + self.failUnlessEqual([], list(FlatPage.objects.all())) + + def test_publish_deletions(self): + self.flat_page.save() + self.flat_page.publish() + public = self.flat_page.public + + self.failUnlessEqual(set([self.flat_page, public]), + set(FlatPage.objects.all())) + + self.flat_page.delete() + self.failUnlessEqual(set([self.flat_page, public]), + set(FlatPage.objects.all())) + + self.flat_page.publish() + self.failUnlessEqual([], list(FlatPage.objects.all())) + + def test_publish_deletions_checks_all_published(self): + # make sure publish_deletions looks at all_published arg + # to see if we need to actually publish the deletion + self.flat_page.save() + self.flat_page.publish() + public = self.flat_page.public + + self.flat_page.delete() + + self.failUnlessEqual(set([self.flat_page, public]), + set(FlatPage.objects.all())) + + # this should effectively stop the deletion happening + all_published = NestedSet() + all_published.add(self.flat_page) + + self.flat_page.publish(all_published=all_published) + self.failUnlessEqual(set([self.flat_page, public]), + set(FlatPage.objects.all())) diff --git a/publish/tests/test_delete_selected.py b/publish/tests/test_delete_selected.py new file mode 100644 index 0000000..a97b810 --- /dev/null +++ b/publish/tests/test_delete_selected.py @@ -0,0 +1,54 @@ +from django.conf import settings +from django.conf.urls import patterns, include +from django.contrib.admin import AdminSite +from django.core.exceptions import PermissionDenied +from django.test import TestCase +from publish.actions import delete_selected +from publish.admin import PublishableAdmin +from publish.tests.example_app.models import FlatPage + + +class TestDeleteSelected(TestCase): + def setUp(self): + super(TestDeleteSelected, self).setUp() + self.fp1 = FlatPage.objects.create(url='/fp1', title='FP1') + self.fp2 = FlatPage.objects.create(url='/fp2', title='FP2') + self.fp3 = FlatPage.objects.create(url='/fp3', title='FP3') + + self.fp1.publish() + self.fp2.publish() + self.fp3.publish() + + self.admin_site = AdminSite('Test Admin') + self.page_admin = PublishableAdmin(FlatPage, self.admin_site) + + # override urls, so reverse works + settings.ROOT_URLCONF = patterns( + '', + ('^admin/', + include(self.admin_site.urls)), + ) + + def test_delete_selected_check_cannot_delete_public(self): + # delete won't work (via admin) for public instances + request = None + self.assertRaises(PermissionDenied, delete_selected, self.page_admin, + request, FlatPage.objects.published()) + + def test_delete_selected(self): + class dummy_request(object): + POST = {} + META = {} + + class user(object): + @classmethod + def has_perm(cls, *arg): + return True + + @classmethod + def get_and_delete_messages(cls): + return [] + + response = delete_selected(self.page_admin, dummy_request, + FlatPage.objects.draft()) + self.failUnless(response is not None) diff --git a/publish/tests/test_infinite_recursion.py b/publish/tests/test_infinite_recursion.py new file mode 100644 index 0000000..f743453 --- /dev/null +++ b/publish/tests/test_infinite_recursion.py @@ -0,0 +1,16 @@ +from django.test import TestCase +from publish.tests.example_app.models import Page + + +class TestInfiniteRecursion(TestCase): + def setUp(self): + super(TestInfiniteRecursion, self).setUp() + + self.page1 = Page.objects.create(slug='page1', title='page 1') + self.page2 = Page.objects.create(slug='page2', title='page 2', + parent=self.page1) + self.page1.parent = self.page2 + self.page1.save() + + def test_publish_recursion_breaks(self): + self.page1.publish() # this should simple run without an error diff --git a/publish/tests/test_many_to_many_through.py b/publish/tests/test_many_to_many_through.py new file mode 100644 index 0000000..a481064 --- /dev/null +++ b/publish/tests/test_many_to_many_through.py @@ -0,0 +1,20 @@ +from django.test import TestCase +from publish.tests.example_app.models import Page, Tag, PageTagOrder + + +class TestManyToManyThrough(TestCase): + def setUp(self): + super(TestManyToManyThrough, self).setUp() + self.page = Page.objects.create(slug='p1', title='P 1') + self.tag1 = Tag.objects.create(slug='tag1', title='Tag 1') + self.tag2 = Tag.objects.create(slug='tag2', title='Tag 2') + PageTagOrder.objects.create(tagged_page=self.page, page_tag=self.tag1, + tag_order=2) + PageTagOrder.objects.create(tagged_page=self.page, page_tag=self.tag2, + tag_order=1) + + def test_publish_copies_tags(self): + self.page.publish() + + self.failUnlessEqual(set([self.tag1, self.tag2]), + set(self.page.public.tags.all())) diff --git a/publish/tests/test_nested_set.py b/publish/tests/test_nested_set.py new file mode 100644 index 0000000..86a81a2 --- /dev/null +++ b/publish/tests/test_nested_set.py @@ -0,0 +1,75 @@ +from django.test import TestCase +from publish.utils import NestedSet + + +class TestNestedSet(TestCase): + def setUp(self): + super(TestNestedSet, self).setUp() + self.nested = NestedSet() + + def test_len(self): + self.failUnlessEqual(0, len(self.nested)) + self.nested.add('one') + self.failUnlessEqual(1, len(self.nested)) + self.nested.add('two') + self.failUnlessEqual(2, len(self.nested)) + self.nested.add('one2', parent='one') + self.failUnlessEqual(3, len(self.nested)) + + def test_contains(self): + self.failIf('one' in self.nested) + self.nested.add('one') + self.failUnless('one' in self.nested) + self.nested.add('one2', parent='one') + self.failUnless('one2' in self.nested) + + def test_nested_items(self): + self.failUnlessEqual([], self.nested.nested_items()) + self.nested.add('one') + self.failUnlessEqual(['one'], self.nested.nested_items()) + self.nested.add('two') + self.nested.add('one2', parent='one') + self.failUnlessEqual(['one', ['one2'], 'two'], + self.nested.nested_items()) + self.nested.add('one2-1', parent='one2') + self.nested.add('one2-2', parent='one2') + self.failUnlessEqual(['one', ['one2', ['one2-1', 'one2-2']], 'two'], + self.nested.nested_items()) + + def test_iter(self): + self.failUnlessEqual(set(), set(self.nested)) + + self.nested.add('one') + self.failUnlessEqual(set(['one']), set(self.nested)) + + self.nested.add('two', parent='one') + self.failUnlessEqual(set(['one', 'two']), set(self.nested)) + + items = set(['one', 'two']) + + for item in self.nested: + self.failUnless(item in items) + items.remove(item) + + self.failUnlessEqual(set(), items) + + def test_original(self): + class MyObject(object): + def __init__(self, obj): + self.obj = obj + + def __eq__(self, other): + return self.obj == other.obj + + def __hash__(self): + return hash(self.obj) + + # should always return an item at least + self.failUnlessEqual(MyObject('hi there'), + self.nested.original(MyObject('hi there'))) + + m1 = MyObject('m1') + self.nested.add(m1) + + self.failUnlessEqual(id(m1), id(self.nested.original(m1))) + self.failUnlessEqual(id(m1), id(self.nested.original(MyObject('m1')))) diff --git a/publish/tests/test_overlapping_publish.py b/publish/tests/test_overlapping_publish.py new file mode 100644 index 0000000..07165f9 --- /dev/null +++ b/publish/tests/test_overlapping_publish.py @@ -0,0 +1,78 @@ +from django.test import TestCase +from publish.tests.example_app.models import Page +from publish.utils import NestedSet + + +class TestOverlappingPublish(TestCase): + def setUp(self): + self.page1 = Page.objects.create(slug='page1', title='page 1') + self.page2 = Page.objects.create(slug='page2', title='page 2') + self.child1 = Page.objects.create(parent=self.page1, slug='child1', + title='Child 1') + self.child2 = Page.objects.create(parent=self.page1, slug='child2', + title='Child 2') + self.child3 = Page.objects.create(parent=self.page2, slug='child3', + title='Child 3') + + def test_publish_with_overlapping_models(self): + # make sure when we publish we don't accidentally create + # multiple published versions + self.failUnlessEqual(5, Page.objects.draft().count()) + self.failUnlessEqual(0, Page.objects.published().count()) + + Page.objects.draft().publish() + + self.failUnlessEqual(5, Page.objects.draft().count()) + self.failUnlessEqual(5, Page.objects.published().count()) + + def test_publish_with_overlapping_models_published(self): + # make sure when we publish we don't accidentally create + # multiple published versions + self.failUnlessEqual(5, Page.objects.draft().count()) + self.failUnlessEqual(0, Page.objects.published().count()) + + all_published = NestedSet() + Page.objects.draft().publish(all_published) + + self.failUnlessEqual(5, len(all_published)) + + self.failUnlessEqual(5, Page.objects.draft().count()) + self.failUnlessEqual(5, Page.objects.published().count()) + + def test_publish_after_dry_run_handles_caching(self): + # if we do a dry tun publish in the same queryset + # before publishing for real, we have to make + # sure we don't run into issues with the instance + # caching parent's as None + self.failUnlessEqual(5, Page.objects.draft().count()) + self.failUnlessEqual(0, Page.objects.published().count()) + + draft = Page.objects.draft() + + all_published = NestedSet() + for p in draft: + p.publish(dry_run=True, all_published=all_published) + + # nothing published yet + self.failUnlessEqual(5, Page.objects.draft().count()) + self.failUnlessEqual(0, Page.objects.published().count()) + + # now publish (using same queryset, + # as this will have cached the instances) + draft.publish() + + self.failUnlessEqual(5, Page.objects.draft().count()) + self.failUnlessEqual(5, Page.objects.published().count()) + + # now actually check the public parent's are setup right + page1 = Page.objects.get(id=self.page1.id) + page2 = Page.objects.get(id=self.page2.id) + child1 = Page.objects.get(id=self.child1.id) + child2 = Page.objects.get(id=self.child2.id) + child3 = Page.objects.get(id=self.child3.id) + + self.failUnlessEqual(None, page1.public.parent) + self.failUnlessEqual(None, page2.public.parent) + self.failUnlessEqual(page1.public, child1.public.parent) + self.failUnlessEqual(page1.public, child2.public.parent) + self.failUnlessEqual(page2.public, child3.public.parent) diff --git a/publish/tests/test_publish_function.py b/publish/tests/test_publish_function.py new file mode 100644 index 0000000..bb9bd29 --- /dev/null +++ b/publish/tests/test_publish_function.py @@ -0,0 +1,22 @@ +from django.test import TestCase +from publish.tests.example_app.models import Page, update_pub_date + + +class TestPublishFunction(TestCase): + def setUp(self): + super(TestPublishFunction, self).setUp() + self.page = Page.objects.create(slug='page', title='Page') + + def test_publish_function_invoked(self): + # check we can override default copy behaviour + + from datetime import datetime + + pub_date = datetime(2000, 1, 1) + update_pub_date.pub_date = pub_date + + self.failIfEqual(pub_date, self.page.pub_date) + + self.page.publish() + self.failIfEqual(pub_date, self.page.pub_date) + self.failUnlessEqual(pub_date, self.page.public.pub_date) diff --git a/publish/tests/test_publish_selected_action.py b/publish/tests/test_publish_selected_action.py new file mode 100644 index 0000000..eba4ee9 --- /dev/null +++ b/publish/tests/test_publish_selected_action.py @@ -0,0 +1,203 @@ +from django.conf import settings +from django.conf.urls import patterns, include +from django.contrib.admin import AdminSite +from django.core.exceptions import PermissionDenied +from django.test import TestCase +from publish.actions import publish_selected, _convert_all_published_to_html +from publish.admin import PublishableAdmin +from publish.tests.example_app.models import Page, PageBlock, Author +from publish.utils import NestedSet + + +class TestPublishSelectedAction(TestCase): + def setUp(self): + super(TestPublishSelectedAction, self).setUp() + self.fp1 = Page.objects.create(slug='fp1', title='FP1') + self.fp2 = Page.objects.create(slug='fp2', title='FP2') + self.fp3 = Page.objects.create(slug='fp3', title='FP3') + + self.admin_site = AdminSite('Test Admin') + self.page_admin = PublishableAdmin(Page, self.admin_site) + + # override urls, so reverse works + settings.ROOT_URLCONF = patterns( + '', + ('^admin/', + include(self.admin_site.urls)), + ) + + def test_publish_selected_confirm(self): + pages = Page.objects.exclude(id=self.fp3.id) + + class dummy_request(object): + META = {} + POST = {} + + class user(object): + @classmethod + def has_perm(cls, *arg): + return True + + @classmethod + def get_and_delete_messages(cls): + return [] + + response = publish_selected(self.page_admin, dummy_request, pages) + + self.failIf(Page.objects.published().count() > 0) + self.failUnless(response is not None) + self.failUnlessEqual(200, response.status_code) + + def test_publish_selected_confirmed(self): + pages = Page.objects.exclude(id=self.fp3.id) + + class dummy_request(object): + POST = {'post': True} + + class user(object): + @classmethod + def is_authenticated(cls): + return True + + @classmethod + def has_perm(cls, *arg): + return True + + class message_set(object): + @classmethod + def create(cls, message=None): + self._message = message + + class _messages(object): + @classmethod + def add(cls, *message): + self._message = message + + response = publish_selected(self.page_admin, dummy_request, pages) + + self.failUnlessEqual(2, Page.objects.published().count()) + self.failUnless(getattr(self, '_message', None) is not None) + self.failUnless(response is None) + + def test_convert_all_published_to_html(self): + self.admin_site.register(Page, PublishableAdmin) + + all_published = NestedSet() + + page = Page.objects.create(slug='here', title='title') + block = PageBlock.objects.create(page=page, content='stuff here') + + all_published.add(page) + all_published.add(block, parent=page) + + converted = _convert_all_published_to_html(self.admin_site, + all_published) + + expected = [ + u'Page: here (Changed - ' + u'not yet published)' % page.id, + [u'Page block: PageBlock object']] + + self.failUnlessEqual(expected, converted) + + def test_publish_selected_does_not_have_permission(self): + self.admin_site.register(Page, PublishableAdmin) + pages = Page.objects.exclude(id=self.fp3.id) + + class dummy_request(object): + POST = {} + META = {} + + class user(object): + @classmethod + def has_perm(cls, *arg): + return False + + @classmethod + def get_and_delete_messages(cls): + return [] + + response = publish_selected(self.page_admin, dummy_request, pages) + self.failIf(response is None) + # publish button should not be in response + self.failIf('value="publish_selected"' in response.content) + self.failIf('value="Yes, Publish"' in response.content) + self.failIf('form' in response.content) + + self.failIf(Page.objects.published().count() > 0) + + def test_publish_selected_does_not_have_related_permission(self): + # check we can't publish when we don't have permission + # for a related model (in this case authors) + self.admin_site.register(Author, PublishableAdmin) + + author = Author.objects.create(name='John') + self.fp1.authors.add(author) + + pages = Page.objects.draft() + + class dummy_request(object): + POST = {'post': True} + + class _messages(object): + @classmethod + def add(cls, *args): + return 'message' + + class user(object): + pk = 1 + + @classmethod + def is_authenticated(cls): + return True + + @classmethod + def has_perm(cls, perm): + return perm != 'example_app.publish_author' + + try: + publish_selected(self.page_admin, dummy_request, pages) + + self.fail() + except PermissionDenied: + pass + + self.failIf(Page.objects.published().count() > 0) + + def test_publish_selected_logs_publication(self): + self.admin_site.register(Page, PublishableAdmin) + + pages = Page.objects.exclude(id=self.fp3.id) + + class dummy_request(object): + POST = {'post': True} + + class user(object): + pk = 1 + + @classmethod + def is_authenticated(cls): + return True + + @classmethod + def has_perm(cls, perm): + return perm != 'example_app.publish_author' + + class message_set(object): + @classmethod + def create(cls, message=None): + pass + + class _messages(object): + @classmethod + def add(cls, *message): + pass + + publish_selected(self.page_admin, dummy_request, pages) + + # should have logged two publications + from django.contrib.admin.models import LogEntry + from django.contrib.contenttypes.models import ContentType + + ContentType.objects.get_for_model(self.fp1).pk + self.failUnlessEqual(2, LogEntry.objects.filter().count()) diff --git a/publish/tests/test_publish_signal.py b/publish/tests/test_publish_signal.py new file mode 100644 index 0000000..cdea9c9 --- /dev/null +++ b/publish/tests/test_publish_signal.py @@ -0,0 +1,114 @@ +from django.test import TestCase +from publish.models import Publishable +from publish.signals import pre_publish, post_publish +from publish.tests.example_app.models import Page + + +class TestPublishSignals(TestCase): + def setUp(self): + self.page1 = Page.objects.create(slug='page1', title='page 1') + self.page2 = Page.objects.create(slug='page2', title='page 2') + self.child1 = Page.objects.create(parent=self.page1, slug='child1', + title='Child 1') + self.child2 = Page.objects.create(parent=self.page1, slug='child2', + title='Child 2') + self.child3 = Page.objects.create(parent=self.page2, slug='child3', + title='Child 3') + + self.failUnlessEqual(5, Page.objects.draft().count()) + + def _check_pre_publish(self, queryset): + pre_published = [] + + def pre_publish_handler(sender, instance, **kw): + pre_published.append(instance) + + pre_publish.connect(pre_publish_handler, sender=Page) + + queryset.draft().publish() + + self.failUnlessEqual(queryset.draft().count(), len(pre_published)) + self.failUnlessEqual(set(queryset.draft()), set(pre_published)) + + def test_pre_publish(self): + # page order shouldn't matter when publishing + # should always get the right number of signals + self._check_pre_publish(Page.objects.order_by('id')) + self._check_pre_publish(Page.objects.order_by('-id')) + self._check_pre_publish(Page.objects.order_by('?')) + + def _check_post_publish(self, queryset): + published = [] + + def post_publish_handler(sender, instance, **kw): + published.append(instance) + + post_publish.connect(post_publish_handler, sender=Page) + + queryset.draft().publish() + + self.failUnlessEqual(queryset.draft().count(), len(published)) + self.failUnlessEqual(set(queryset.draft()), set(published)) + + def test_post_publish(self): + self._check_post_publish(Page.objects.order_by('id')) + self._check_post_publish(Page.objects.order_by('-id')) + self._check_post_publish(Page.objects.order_by('?')) + + def test_signals_sent_for_followed(self): + pre_published = [] + + def pre_publish_handler(sender, instance, **kw): + pre_published.append(instance) + + pre_publish.connect(pre_publish_handler, sender=Page) + + published = [] + + def post_publish_handler(sender, instance, **kw): + published.append(instance) + + post_publish.connect(post_publish_handler, sender=Page) + + # publishing just children will also publish it's parent (if needed) + # which should also fire signals + + self.child1.publish() + + self.failUnlessEqual(set([self.page1, self.child1]), + set(pre_published)) + self.failUnlessEqual(set([self.page1, self.child1]), set(published)) + + def test_deleted_flag_false_when_publishing_change(self): + def pre_publish_handler(sender, instance, deleted, **kw): + self.failIf(deleted) + + pre_publish.connect(pre_publish_handler, sender=Page) + + def post_publish_handler(sender, instance, deleted, **kw): + self.failIf(deleted) + + post_publish.connect(post_publish_handler, sender=Page) + + self.page1.publish() + + def test_deleted_flag_true_when_publishing_deletion(self): + self.child1.publish() + self.child1.public + + self.child1.delete() + + self.failUnlessEqual(Publishable.PUBLISH_DELETE, + self.child1.publish_state) + + def pre_publish_handler(sender, instance, deleted, **kw): + self.failUnless(deleted) + + pre_publish.connect(pre_publish_handler, sender=Page) + + def post_publish_handler(sender, instance, deleted, **kw): + self.failUnless(deleted) + + post_publish.connect(post_publish_handler, sender=Page) + + self.child1.publish() diff --git a/publish/tests/test_publishable_admin.py b/publish/tests/test_publishable_admin.py new file mode 100644 index 0000000..4c09106 --- /dev/null +++ b/publish/tests/test_publishable_admin.py @@ -0,0 +1,406 @@ +from django.conf import settings +from django.conf.urls import patterns, include +from django.contrib.admin import AdminSite +from django.contrib.auth.models import User, AnonymousUser +from django.core.exceptions import PermissionDenied +from django.forms import ModelChoiceField, ModelMultipleChoiceField +from django.http import Http404 +from django.test import TestCase, RequestFactory +from publish.admin import PublishableStackedInline, PublishableAdmin +from publish.models import Publishable +from publish.tests.example_app.models import Page, Author, PageBlock +from publish.tests.helpers import _get_rendered_content + + +class TestPublishableAdmin(TestCase): + def setUp(self): + super(TestPublishableAdmin, self).setUp() + self.page1 = Page.objects.create(slug='page1', title='page 1') + self.page2 = Page.objects.create(slug='page2', title='page 2') + self.page1.publish() + self.page2.publish() + + self.author1 = Author.objects.create(name='a1') + self.author2 = Author.objects.create(name='a2') + self.author1.publish() + self.author2.publish() + + self.admin_site = AdminSite('Test Admin') + + class PageBlockInline(PublishableStackedInline): + model = PageBlock + + class PageAdmin(PublishableAdmin): + inlines = [PageBlockInline] + + self.admin_site.register(Page, PageAdmin) + self.page_admin = PageAdmin(Page, self.admin_site) + + # override urls, so reverse works + settings.ROOT_URLCONF = patterns( + '', + ('^admin/', + include(self.admin_site.urls)), + ) + + def test_get_publish_status_display(self): + page = Page.objects.create(slug="hhkkk", title="hjkhjkh") + self.failUnlessEqual('Changed - not yet published', + self.page_admin.get_publish_status_display(page)) + page.publish() + self.failUnlessEqual('Published', + self.page_admin.get_publish_status_display(page)) + page.save() + self.failUnlessEqual('Changed', + self.page_admin.get_publish_status_display(page)) + + page.delete() + self.failUnlessEqual('To be deleted', + self.page_admin.get_publish_status_display(page)) + + def test_queryset(self): + # make sure we only get back draft objects + request = None + + self.failUnlessEqual( + set([self.page1, self.page1.public, self.page2, + self.page2.public]), + set(Page.objects.all()) + ) + self.failUnlessEqual( + set([self.page1, self.page2]), + set(self.page_admin.queryset(request)) + ) + + def test_get_actions_global_delete_replaced(self): + from publish.actions import delete_selected + + class request(object): + GET = {} + + actions = self.page_admin.get_actions(request) + + self.failUnless('delete_selected' in actions) + action, name, description = actions['delete_selected'] + self.failUnlessEqual(delete_selected, action) + self.failUnlessEqual('delete_selected', name) + self.failUnlessEqual(delete_selected.short_description, description) + + def test_formfield_for_foreignkey(self): + # foreign key forms fields in admin + # for publishable models should be filtered + # to hide public object + + request = None + parent_field = None + for field in Page._meta.fields: + if field.name == 'parent': + parent_field = field + break + self.failUnless(parent_field) + + choice_field = self.page_admin.formfield_for_foreignkey(parent_field, + request) + self.failUnless(choice_field) + self.failUnless(isinstance(choice_field, ModelChoiceField)) + + self.failUnlessEqual( + set([self.page1, self.page1.public, self.page2, + self.page2.public]), + set(Page.objects.all()) + ) + self.failUnlessEqual( + set([self.page1, self.page2]), + set(choice_field.queryset) + ) + + def test_formfield_for_manytomany(self): + request = None + authors_field = None + for field in Page._meta.many_to_many: + if field.name == 'authors': + authors_field = field + break + self.failUnless(authors_field) + + choice_field = self.page_admin.formfield_for_manytomany(authors_field, + request) + self.failUnless(choice_field) + self.failUnless(isinstance(choice_field, ModelMultipleChoiceField)) + + self.failUnlessEqual( + set([self.author1, self.author1.public, self.author2, + self.author2.public]), + set(Author.objects.all()) + ) + self.failUnlessEqual( + set([self.author1, self.author2]), + set(choice_field.queryset) + ) + + def test_has_change_permission(self): + class dummy_request(object): + method = 'GET' + REQUEST = {} + + class user(object): + @classmethod + def has_perm(cls, permission): + return True + + self.failUnless(self.page_admin.has_change_permission(dummy_request)) + self.failUnless( + self.page_admin.has_change_permission(dummy_request, self.page1)) + self.failIf(self.page_admin.has_change_permission(dummy_request, + self.page1.public)) + + # can view deleted items + self.page1.publish_state = Publishable.PUBLISH_DELETE + self.failUnless( + self.page_admin.has_change_permission(dummy_request, self.page1)) + + # but cannot modify them + dummy_request.method = 'POST' + self.failIf( + self.page_admin.has_change_permission(dummy_request, self.page1)) + + def test_has_delete_permission(self): + class dummy_request(object): + method = 'GET' + REQUEST = {} + + class user(object): + @classmethod + def has_perm(cls, permission): + return True + + self.failUnless(self.page_admin.has_delete_permission(dummy_request)) + self.failUnless( + self.page_admin.has_delete_permission(dummy_request, self.page1)) + self.failIf(self.page_admin.has_delete_permission(dummy_request, + self.page1.public)) + + def test_change_view_normal(self): + class dummy_request(object): + method = 'GET' + REQUEST = {} + COOKIES = {} + META = {} + + @classmethod + def is_ajax(cls): + return False + + @classmethod + def is_secure(cls): + return False + + class user(object): + @classmethod + def has_perm(cls, permission): + return True + + @classmethod + def get_and_delete_messages(cls): + return [] + + response = self.page_admin.change_view(dummy_request, + str(self.page1.id)) + self.failUnless(response is not None) + self.failIf('deleted' in _get_rendered_content(response)) + + def test_change_view_not_deleted(self): + class dummy_request(object): + method = 'GET' + COOKIES = {} + META = {} + + @classmethod + def is_ajax(cls): + return False + + @classmethod + def is_secure(cls): + return False + + class user(object): + @classmethod + def has_perm(cls, permission): + return True + + try: + self.page_admin.change_view(dummy_request, + unicode(self.page1.public.id)) + self.fail() + except Http404: + pass + + def test_change_view_deleted(self): + class dummy_request(object): + method = 'GET' + REQUEST = {} + COOKIES = {} + META = {} + + @classmethod + def is_ajax(cls): + return False + + @classmethod + def is_secure(cls): + return False + + class user(object): + @classmethod + def has_perm(cls, permission): + return True + + @classmethod + def get_and_delete_messages(cls): + return [] + + self.page1.delete() + + response = self.page_admin.change_view(dummy_request, + str(self.page1.id)) + self.failUnless(response is not None) + self.failUnless('deleted' in _get_rendered_content(response)) + + def test_change_view_deleted_POST(self): + class dummy_request(object): + csrf_processing_done = True + method = 'POST' + COOKIES = {} + META = {} + POST = {} + + @classmethod + def is_ajax(cls): + return False + + @classmethod + def is_secure(cls): + return False + + self.page1.delete() + + try: + self.page_admin.change_view(dummy_request, str(self.page1.id)) + self.fail() + except PermissionDenied: + pass + + def test_change_view_delete_inline(self): + block = PageBlock.objects.create(page=self.page1, + content='some content') + page1 = Page.objects.get(pk=self.page1.pk) + page1.publish() + + user1 = User.objects.create_user('test1', 'test@example.com', 'jkljkl') + + # fake selecting the delete tickbox for the block + + class dummy_request(object): + csrf_processing_done = True + method = 'POST' + + POST = { + 'slug': page1.slug, + 'title': page1.title, + 'content': page1.content, + 'pub_date_0': '2010-02-12', + 'pub_date_1': '17:40:00', + 'pageblock_set-TOTAL_FORMS': '2', + 'pageblock_set-INITIAL_FORMS': '1', + 'pageblock_set-0-id': str(block.id), + 'pageblock_set-0-page': str(page1.id), + 'pageblock_set-0-DELETE': 'yes' + } + REQUEST = POST + FILES = {} + COOKIES = {} + META = {} + + @classmethod + def is_ajax(cls): + return False + + @classmethod + def is_secure(cls): + return False + + class user(object): + pk = user1.pk + + @classmethod + def is_authenticated(self): + return True + + @classmethod + def has_perm(cls, permission): + return True + + @classmethod + def get_and_delete_messages(cls): + return [] + + class message_set(object): + @classmethod + def create(cls, message=''): + pass + + class _messages(object): + @classmethod + def add(cls, *message): + pass + + block = PageBlock.objects.get(id=block.id) + public_block = block.public + + response = self.page_admin.change_view(dummy_request, str(page1.id)) + self.assertEqual(302, response.status_code) + + # the block should have been deleted (but not the public one) + self.failUnlessEqual([public_block], list(PageBlock.objects.all())) + + +class PublishPage(TestCase): + def setUp(self): + super(PublishPage, self).setUp() + self.admin_site = AdminSite('Test Admin') + + class PageAdmin(PublishableAdmin): + pass + + self.admin_site.register(Page, PageAdmin) + self.page_admin = PageAdmin(Page, self.admin_site) + + def test_should_be_publish(self): + self.page1 = Page.objects.create(slug='page1', title='page 1') + + user1 = User.objects.create_superuser('test1', 'test@example.com', + 'pass') + + self.factory = RequestFactory() + request = self.factory.post('/publish/change_view', + data={'_publish': ''}) + request.user = user1 + + self.page_admin.change_view(request, str(self.page1.id)) + self.assertEqual(Page.objects.filter(Page.Q_PUBLISHED, + slug=self.page1.slug).count(), 1) + + def test_should_be_republish(self): + self.page1 = Page.objects.create(slug='page1', title='page 1') + self.page1.publish() + user1 = User.objects.create_superuser('test1', 'test@example.com', + 'pass') + + self.factory = RequestFactory() + request = self.factory.post('/publish/change_view', + data={'_publish': ''}) + request.user = user1 + + self.page_admin.change_view(request, str(self.page1.id)) + self.assertEqual(Page.objects.filter(Page.Q_PUBLISHED, + slug=self.page1.slug).count(), 1) diff --git a/publish/tests/test_publishable_manager.py b/publish/tests/test_publishable_manager.py new file mode 100644 index 0000000..fe52805 --- /dev/null +++ b/publish/tests/test_publishable_manager.py @@ -0,0 +1,110 @@ +from django.test import TestCase +from publish.tests.example_app.models import FlatPage + + +class TestPublishableManager(TestCase): + def setUp(self): + super(TestCase, self).setUp() + self.flat_page1 = FlatPage.objects.create(url='/url1/', + title='title 1') + self.flat_page2 = FlatPage.objects.create(url='/url2/', + title='title 2') + + def test_all(self): + self.failUnlessEqual([self.flat_page1, self.flat_page2], + list(FlatPage.objects.all())) + + # publishing will produce extra copies + self.flat_page1.publish() + self.failUnlessEqual(3, FlatPage.objects.count()) + + self.flat_page2.publish() + self.failUnlessEqual(4, FlatPage.objects.count()) + + def test_changed(self): + self.failUnlessEqual([self.flat_page1, self.flat_page2], + list(FlatPage.objects.changed())) + + self.flat_page1.publish() + self.failUnlessEqual([self.flat_page2], + list(FlatPage.objects.changed())) + + self.flat_page2.publish() + self.failUnlessEqual([], list(FlatPage.objects.changed())) + + def test_draft(self): + # draft should stay the same pretty much always + self.failUnlessEqual([self.flat_page1, self.flat_page2], + list(FlatPage.objects.draft())) + + self.flat_page1.publish() + self.failUnlessEqual([self.flat_page1, self.flat_page2], + list(FlatPage.objects.draft())) + + self.flat_page2.publish() + self.failUnlessEqual([self.flat_page1, self.flat_page2], + list(FlatPage.objects.draft())) + + self.flat_page2.delete() + self.failUnlessEqual([self.flat_page1], list(FlatPage.objects.draft())) + + def test_published(self): + self.failUnlessEqual([], list(FlatPage.objects.published())) + + self.flat_page1.publish() + self.failUnlessEqual([self.flat_page1.public], + list(FlatPage.objects.published())) + + self.flat_page2.publish() + self.failUnlessEqual([self.flat_page1.public, self.flat_page2.public], + list(FlatPage.objects.published())) + + def test_deleted(self): + self.failUnlessEqual([], list(FlatPage.objects.deleted())) + + self.flat_page1.publish() + self.failUnlessEqual([], list(FlatPage.objects.deleted())) + + self.flat_page1.delete() + self.failUnlessEqual([self.flat_page1], + list(FlatPage.objects.deleted())) + + def test_draft_and_deleted(self): + self.failUnlessEqual(set([self.flat_page1, self.flat_page2]), + set(FlatPage.objects.draft_and_deleted())) + + self.flat_page1.publish() + self.failUnlessEqual(set([self.flat_page1, self.flat_page2]), + set(FlatPage.objects.draft_and_deleted())) + self.failUnlessEqual(set([self.flat_page1, self.flat_page2]), + set(FlatPage.objects.draft())) + + self.flat_page1.delete() + self.failUnlessEqual(set([self.flat_page1, self.flat_page2]), + set(FlatPage.objects.draft_and_deleted())) + self.failUnlessEqual([self.flat_page2], list(FlatPage.objects.draft())) + + def test_delete(self): + # delete is overriden, so it marks the public instances + self.flat_page1.publish() + public1 = self.flat_page1.public + + FlatPage.objects.draft().delete() + + self.failUnlessEqual([], list(FlatPage.objects.draft())) + self.failUnlessEqual([self.flat_page1], + list(FlatPage.objects.deleted())) + self.failUnlessEqual([public1], list(FlatPage.objects.published())) + self.failUnlessEqual([self.flat_page1], + list(FlatPage.objects.draft_and_deleted())) + + def test_publish(self): + self.failUnlessEqual([], list(FlatPage.objects.published())) + + FlatPage.objects.draft().publish() + + flat_page1 = FlatPage.objects.get(id=self.flat_page1.id) + flat_page2 = FlatPage.objects.get(id=self.flat_page2.id) + + self.failUnlessEqual(set([flat_page1.public, flat_page2.public]), + set(FlatPage.objects.published())) diff --git a/publish/tests/test_publishable_many_to_many.py b/publish/tests/test_publishable_many_to_many.py new file mode 100644 index 0000000..59293c6 --- /dev/null +++ b/publish/tests/test_publishable_many_to_many.py @@ -0,0 +1,80 @@ +from django.test import TestCase +from publish.tests.example_app.models import FlatPage, Site + + +class TestPublishableManyToMany(TestCase): + def setUp(self): + super(TestPublishableManyToMany, self).setUp() + self.flat_page = FlatPage.objects.create( + url='/my-page', title='my page', + content='here is some content', + enable_comments=False, + registration_required=True) + self.site1 = Site.objects.create(title='my site', domain='mysite.com') + self.site2 = Site.objects.create(title='a site', domain='asite.com') + + def test_publish_no_sites(self): + self.flat_page.publish() + self.failUnless(self.flat_page.public) + self.failUnlessEqual([], list(self.flat_page.public.sites.all())) + + def test_publish_add_site(self): + self.flat_page.sites.add(self.site1) + self.flat_page.publish() + self.failUnless(self.flat_page.public) + self.failUnlessEqual([self.site1], + list(self.flat_page.public.sites.all())) + + def test_publish_repeated_add_site(self): + self.flat_page.sites.add(self.site1) + self.flat_page.publish() + self.failUnless(self.flat_page.public) + self.failUnlessEqual([self.site1], + list(self.flat_page.public.sites.all())) + + self.flat_page.sites.add(self.site2) + self.failUnlessEqual([self.site1], + list(self.flat_page.public.sites.all())) + + self.flat_page.publish() + self.failUnlessEqual([self.site1, self.site2], + list(self.flat_page.public.sites.order_by('id'))) + + def test_publish_remove_site(self): + self.flat_page.sites.add(self.site1, self.site2) + self.flat_page.publish() + self.failUnless(self.flat_page.public) + self.failUnlessEqual([self.site1, self.site2], + list(self.flat_page.public.sites.order_by('id'))) + + self.flat_page.sites.remove(self.site1) + self.failUnlessEqual([self.site1, self.site2], + list(self.flat_page.public.sites.order_by('id'))) + + self.flat_page.publish() + self.failUnlessEqual([self.site2], + list(self.flat_page.public.sites.all())) + + def test_publish_clear_sites(self): + self.flat_page.sites.add(self.site1, self.site2) + self.flat_page.publish() + self.failUnless(self.flat_page.public) + self.failUnlessEqual([self.site1, self.site2], + list(self.flat_page.public.sites.order_by('id'))) + + self.flat_page.sites.clear() + self.failUnlessEqual([self.site1, self.site2], + list(self.flat_page.public.sites.order_by('id'))) + + self.flat_page.publish() + self.failUnlessEqual([], list(self.flat_page.public.sites.all())) + + def test_publish_sites_cleared_not_deleted(self): + self.flat_page.sites.add(self.site1, self.site2) + self.flat_page.publish() + self.flat_page.sites.clear() + self.flat_page.publish() + + self.failUnlessEqual([], list(self.flat_page.public.sites.all())) + + self.failIfEqual([], list(Site.objects.all())) diff --git a/publish/tests/test_publishable_recursive_fk.py b/publish/tests/test_publishable_recursive_fk.py new file mode 100644 index 0000000..fbd6d17 --- /dev/null +++ b/publish/tests/test_publishable_recursive_fk.py @@ -0,0 +1,188 @@ +from django.test import TestCase +from publish.models import Publishable +from publish.tests.example_app.models import Page, PageBlock, Comment + + +class TestPublishableRecursiveForeignKey(TestCase): + def setUp(self): + super(TestPublishableRecursiveForeignKey, self).setUp() + self.page1 = Page.objects.create(slug='page1', title='page 1', + content='some content') + self.page2 = Page.objects.create(slug='page2', + title='page 2', + content='other content', + parent=self.page1) + + def test_publish_parent(self): + # this shouldn't publish the child page + self.page1.publish() + self.failUnless(self.page1.public) + self.failIf(self.page1.public.parent) + + page2 = Page.objects.get(id=self.page2.id) + self.failIf(page2.public) + + def test_publish_child_parent_already_published(self): + self.page1.publish() + self.page2.publish() + + self.failUnless(self.page1.public) + self.failUnless(self.page2.public) + + self.failIf(self.page1.public.parent) + self.failUnless(self.page2.public.parent) + + self.failIfEqual(self.page1, self.page2.public.parent) + + self.failUnlessEqual('/page1/', + self.page1.public.get_absolute_url()) + self.failUnlessEqual('/page1/page2/', + self.page2.public.get_absolute_url()) + + def test_publish_child_parent_not_already_published(self): + self.page2.publish() + + page1 = Page.objects.get(id=self.page1.id) + self.failUnless(page1.public) + self.failUnless(self.page2.public) + + self.failIf(page1.public.parent) + self.failUnless(self.page2.public.parent) + + self.failIfEqual(page1, self.page2.public.parent) + + self.failUnlessEqual('/page1/', + self.page1.public.get_absolute_url()) + self.failUnlessEqual('/page1/page2/', + self.page2.public.get_absolute_url()) + + def test_publish_repeated(self): + self.page1.publish() + self.page2.publish() + + self.page1.slug = 'main' + self.page1.save() + + self.failUnlessEqual('/main/', self.page1.get_absolute_url()) + + page1 = Page.objects.get(id=self.page1.id) + page2 = Page.objects.get(id=self.page2.id) + self.failUnlessEqual('/page1/', page1.public.get_absolute_url()) + self.failUnlessEqual('/page1/page2/', page2.public.get_absolute_url()) + + page1.publish() + page1 = Page.objects.get(id=self.page1.id) + page2 = Page.objects.get(id=self.page2.id) + self.failUnlessEqual('/main/', page1.public.get_absolute_url()) + self.failUnlessEqual('/main/page2/', page2.public.get_absolute_url()) + + page1.slug = 'elsewhere' + page1.save() + page1 = Page.objects.get(id=self.page1.id) + page2 = Page.objects.get(id=self.page2.id) + page2.slug = 'meanwhile' + page2.save() + page2.publish() + page1 = Page.objects.get(id=self.page1.id) + page2 = Page.objects.get(id=self.page2.id) + + # only page2 should be published, not page1, as page1 already published + self.failUnlessEqual(Publishable.PUBLISH_DEFAULT, page2.publish_state) + self.failUnlessEqual(Publishable.PUBLISH_CHANGED, page1.publish_state) + + self.failUnlessEqual('/main/', page1.public.get_absolute_url()) + self.failUnlessEqual('/main/meanwhile/', + page2.public.get_absolute_url()) + + page1.publish() + page1 = Page.objects.get(id=self.page1.id) + page2 = Page.objects.get(id=self.page2.id) + + self.failUnlessEqual(Publishable.PUBLISH_DEFAULT, page2.publish_state) + self.failUnlessEqual(Publishable.PUBLISH_DEFAULT, page1.publish_state) + + self.failUnlessEqual('/elsewhere/', page1.public.get_absolute_url()) + self.failUnlessEqual('/elsewhere/meanwhile/', + page2.public.get_absolute_url()) + + def test_publish_deletions(self): + self.page1.publish() + self.page2.publish() + + self.page2.delete() + self.failUnlessEqual([self.page2], list(Page.objects.deleted())) + + self.page2.publish() + self.failUnlessEqual([self.page1.public], + list(Page.objects.published())) + self.failUnlessEqual([], list(Page.objects.deleted())) + + def test_publish_reverse_fields(self): + page_block = PageBlock.objects.create(page=self.page1, + content='here we are') + + self.page1.publish() + + public = self.page1.public + self.failUnless(public) + + blocks = list(public.pageblock_set.all()) + self.failUnlessEqual(1, len(blocks)) + self.failUnlessEqual(page_block.content, blocks[0].content) + + def test_publish_deletions_reverse_fields(self): + PageBlock.objects.create(page=self.page1, content='here we are') + + self.page1.publish() + public = self.page1.public + self.failUnless(public) + + self.page1.delete() + + self.failUnlessEqual([self.page1], list(Page.objects.deleted())) + + self.page1.publish() + self.failUnlessEqual([], list(Page.objects.deleted())) + self.failUnlessEqual([], list(Page.objects.all())) + + def test_publish_reverse_fields_deleted(self): + # make sure child elements get removed + page_block = PageBlock.objects.create(page=self.page1, + content='here we are') + + self.page1.publish() + + public = self.page1.public + page_block = PageBlock.objects.get(id=page_block.id) + page_block_public = page_block.public + self.failIf(page_block_public is None) + + self.failUnlessEqual([page_block_public], + list(public.pageblock_set.all())) + + # now delete the page block and publish the parent + # to make sure that deletion gets copied over properly + page_block.delete() + page1 = Page.objects.get(id=self.page1.id) + page1.publish() + public = page1.public + + self.failUnlessEqual([], list(public.pageblock_set.all())) + + def test_publish_delections_with_non_publishable_children(self): + self.page1.publish() + + Comment.objects.create(page=self.page1.public, + comment='This is a comment') + + self.failUnlessEqual(1, Comment.objects.count()) + + self.page1.delete() + + self.failUnlessEqual([self.page1], list(Page.objects.deleted())) + self.failIf(self.page1 in Page.objects.draft()) + + self.page1.publish() + self.failUnlessEqual([], list(Page.objects.deleted())) + self.failUnlessEqual([], list(Page.objects.all())) + self.failUnlessEqual([], list(Comment.objects.all())) diff --git a/publish/tests/test_publishable_recursive_many_to_many_field.py b/publish/tests/test_publishable_recursive_many_to_many_field.py new file mode 100644 index 0000000..f81ef3e --- /dev/null +++ b/publish/tests/test_publishable_recursive_many_to_many_field.py @@ -0,0 +1,61 @@ +from django.test import TestCase +from publish.tests.example_app.models import Page, Author + + +class TestPublishableRecursiveManyToManyField(TestCase): + + def setUp(self): + super(TestPublishableRecursiveManyToManyField, self).setUp() + self.page = Page.objects.create( + slug='page1', title='page 1', content='some content') + self.author1 = Author.objects.create( + name='author1', profile='a profile') + self.author2 = Author.objects.create( + name='author2', profile='something else') + + def test_publish_add_author(self): + self.page.authors.add(self.author1) + self.page.publish() + self.failUnless(self.page.public) + + author1 = Author.objects.get(id=self.author1.id) + self.failUnless(author1.public) + self.failIfEqual(author1.id, author1.public.id) + self.failUnlessEqual(author1.name, author1.public.name) + self.failUnlessEqual(author1.profile, author1.public.profile) + + self.failUnlessEqual([author1.public], + list(self.page.public.authors.all())) + + def test_publish_repeated_add_author(self): + self.page.authors.add(self.author1) + self.page.publish() + + self.failUnless(self.page.public) + + self.page.authors.add(self.author2) + author1 = Author.objects.get(id=self.author1.id) + self.failUnlessEqual([author1.public], + list(self.page.public.authors.all())) + + self.page.publish() + author1 = Author.objects.get(id=self.author1.id) + author2 = Author.objects.get(id=self.author2.id) + self.failUnlessEqual([author1.public, author2.public], + list(self.page.public.authors.order_by('name'))) + + def test_publish_clear_authors(self): + self.page.authors.add(self.author1, self.author2) + self.page.publish() + + author1 = Author.objects.get(id=self.author1.id) + author2 = Author.objects.get(id=self.author2.id) + self.failUnlessEqual([author1.public, author2.public], + list(self.page.public.authors.order_by('name'))) + + self.page.authors.clear() + self.failUnlessEqual([author1.public, author2.public], + list(self.page.public.authors.order_by('name'))) + + self.page.publish() + self.failUnlessEqual([], list(self.page.public.authors.all())) diff --git a/publish/tests/test_publishable_related_filter_spec.py b/publish/tests/test_publishable_related_filter_spec.py new file mode 100644 index 0000000..808329c --- /dev/null +++ b/publish/tests/test_publishable_related_filter_spec.py @@ -0,0 +1,38 @@ +from django.test import TestCase +from publish.admin import PublishableAdmin +from publish.filters import FieldListFilter, PublishableRelatedFieldListFilter +from publish.tests.example_app.models import Page, Author + + +class TestPublishableRelatedFilterSpec(TestCase): + + def test_overridden_spec(self): + # make sure the publishable filter spec + # gets used when we use a publishable field + class dummy_request(object): + GET = {} + + spec = FieldListFilter.create( + Page._meta.get_field('authors'), + dummy_request, {}, Page, PublishableAdmin, None) + self.failUnless(isinstance(spec, PublishableRelatedFieldListFilter)) + + def test_only_draft_shown(self): + self.author = Author.objects.create(name='author') + self.author.publish() + + self.failUnless(2, Author.objects.count()) + + # make sure the publishable filter spec + # gets used when we use a publishable field + class dummy_request(object): + GET = {} + + spec = FieldListFilter.create( + Page._meta.get_field('authors'), dummy_request, {}, + Page, PublishableAdmin, None) + + lookup_choices = spec.lookup_choices + self.failUnlessEqual(1, len(lookup_choices)) + pk, label = lookup_choices[0] + self.failUnlessEqual(self.author.id, pk) diff --git a/publish/tests/test_undelete_selected.py b/publish/tests/test_undelete_selected.py new file mode 100644 index 0000000..b396dc7 --- /dev/null +++ b/publish/tests/test_undelete_selected.py @@ -0,0 +1,56 @@ +from django.contrib.admin import AdminSite +from django.core.exceptions import PermissionDenied +from django.test import TestCase +from publish.actions import undelete_selected +from publish.admin import PublishableAdmin +from publish.models import Publishable +from publish.tests.example_app.models import FlatPage + + +class TestUndeleteSelected(TestCase): + + def setUp(self): + super(TestUndeleteSelected, self).setUp() + self.fp1 = FlatPage.objects.create(url='/fp1', title='FP1') + + self.fp1.publish() + + self.admin_site = AdminSite('Test Admin') + self.page_admin = PublishableAdmin(FlatPage, self.admin_site) + + def test_undelete_selected(self): + class dummy_request(object): + + class user(object): + @classmethod + def has_perm(cls, *arg): + return True + + self.fp1.delete() + self.failUnlessEqual(Publishable.PUBLISH_DELETE, + self.fp1.publish_state) + + response = undelete_selected(self.page_admin, dummy_request, + FlatPage.objects.deleted()) + self.failUnless(response is None) + + # publish state should no longer be delete + fp1 = FlatPage.objects.get(pk=self.fp1.pk) + self.failUnlessEqual(Publishable.PUBLISH_CHANGED, fp1.publish_state) + + def test_undelete_selected_no_permission(self): + class dummy_request(object): + + class user(object): + @classmethod + def has_perm(cls, *arg): + return False + + self.fp1.delete() + self.failUnlessEqual(Publishable.PUBLISH_DELETE, + self.fp1.publish_state) + + self.assertRaises(PermissionDenied, + undelete_selected, + self.page_admin, dummy_request, + FlatPage.objects.deleted()) diff --git a/publish/utils.py b/publish/utils.py index 4dfe2ee..f72c8e4 100644 --- a/publish/utils.py +++ b/publish/utils.py @@ -1,27 +1,26 @@ - class NestedSet(object): ''' a class that can be used a bit like a set, but will let us store hiearchy too ''' - + def __init__(self): self._root_elements = [] self._children = {} - + def add(self, item, parent=None): if parent is None: self._root_elements.append(item) else: self._children[parent].append(item) - self._children[item]=[] + self._children[item] = [] def __contains__(self, item): return item in self._children - + def __len__(self): return len(self._children) - + def __iter__(self): return iter(self._children) @@ -32,7 +31,6 @@ def original(self, item): if child == item: return child return item - def _add_nested_items(self, items, nested): for item in items: @@ -45,7 +43,7 @@ def _nested_children(self, item): children = [] self._add_nested_items(self._children[item], children) return children - + def nested_items(self): items = [] self._add_nested_items(self._root_elements, items) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bea531e --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Django<1.6 diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..e182035 --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,3 @@ +nose==1.3.0 +django-nose==1.1 +coveralls==0.2 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..d07b51f --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[nosetests] +detailed-errors=1 +with-coverage=1 +cover-package=publish +debug=nose.loader \ No newline at end of file diff --git a/setup.py b/setup.py index 5b9a6a8..d5c9a0f 100644 --- a/setup.py +++ b/setup.py @@ -1,18 +1,50 @@ -from setuptools import setup, find_packages +import re +from setuptools import setup, find_packages, findall -version=__import__('publish').__version__ +version = __import__('publish').__version__ + + +def parse_requirements(file_name): + requirements = [] + for line in open(file_name, 'r').read().split('\n'): + if re.match(r'(\s*#)|(\s*$)', line): + continue + if re.match(r'\s*-e\s+', line): + requirements.append(re.sub(r'\s*-e\s+.*#egg=(.*)$', r'\1', line)) + elif re.match(r'\s*-f\s+', line): + pass + else: + requirements.append(line) + return requirements + + +def not_py(file_path): + return not (file_path.endswith('.py') or file_path.endswith('.pyc')) + + +core_packages = find_packages() +core_package_data = {} +for package in core_packages: + package_path = package.replace('.', '/') + core_package_data[package] = filter(not_py, findall(package_path)) + +download_url = 'https://github.com/johnsensible/django-publish/archive/v%s.zip#egg=django-publish-%s' % ( + version, version +) setup( name='django-publish', version=version, - description='Handy mixin/abstract class for providing a "publisher workflow" to arbitrary Django models.', + description='Handy mixin/abstract class for providing a "publisher ' + 'workflow" to arbitrary Django models.', long_description=open('README.rst').read(), author='John Montgomery', author_email='john@sensibledevelopment.com', url='http://github.com/johnsensible/django-publish', - download_url='https://github.com/johnsensible/django-publish/archive/v%s.zip#egg=django-publish-%s' % (version, version), + download_url=download_url, license='BSD', - packages=find_packages(exclude=['ez_setup']), + packages=core_packages, + package_data=core_package_data, include_package_data=True, zip_safe=True, classifiers=[ @@ -25,4 +57,6 @@ 'Programming Language :: Python', 'Topic :: Software Development :: Libraries :: Python Modules', ], + install_requires=parse_requirements('requirements.txt'), + setup_requires=parse_requirements('requirements_test.txt') ) diff --git a/tests/run_tests.sh b/tests/run_tests.sh deleted file mode 100755 index 2a00333..0000000 --- a/tests/run_tests.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -# run from parent directory (e.g. tests/run_tests.sh) -django-admin.py test publish --pythonpath=. --pythonpath=tests --settings=test_settings diff --git a/tests/test_settings.py b/tests/test_settings.py deleted file mode 100644 index a38d7f7..0000000 --- a/tests/test_settings.py +++ /dev/null @@ -1,52 +0,0 @@ -DEBUG = True -TEMPLATE_DEBUG = DEBUG -DATABASE_ENGINE = 'sqlite3' -DATABASE_NAME = ':memory:' - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'. - 'NAME': ':memory:', # Or path to database file if using sqlite3. - 'USER': '', # Not used with sqlite3. - 'PASSWORD': '', # Not used with sqlite3. - } -} - -import django - -if django.VERSION < (1,4): - INSTALLED_APPS = ( - 'django.contrib.contenttypes', - 'django.contrib.admin', - 'django.contrib.auth', - 'publish', - ) - - TEMPLATE_LOADERS = ( - 'django.template.loaders.filesystem.load_template_source', - 'django.template.loaders.app_directories.load_template_source', - ) -else: - INSTALLED_APPS = ( - 'django.contrib.contenttypes', - 'django.contrib.admin', - 'django.contrib.auth', - 'publish', - ) - - TEMPLATE_LOADERS = ( - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', - ) - MIDDLEWARE_CLASSES = [ - 'django.contrib.messages.middleware.MessageMiddleware', - ] - - - -TESTING_PUBLISH=True - -# enable this for coverage (using django test coverage -# http://pypi.python.org/pypi/django-test-coverage ) -#TEST_RUNNER = 'django-test-coverage.runner.run_tests' -#COVERAGE_MODULES = ('publish.models', 'publish.admin', 'publish.actions', 'publish.utils', 'publish.signals')