Commits

Yusuke MURAOKA committed af1f49c

init

Comments (0)

Files changed (15)

+Copyright (c) 2014, PyCon JP
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+include LICENSE
+include README.rst
+recursive-include restcms/templates *
+=======
+restcms
+=======
+
+Quick start
+-----------
+
+1. Add "restcms" to your INSTALLED_APPS setting like this::
+
+    INSTALLED_APPS = (
+        ...
+        'restcms',
+    )
+
+2. Include the restcms URLconf in your project urls.py like this::
+
+    url(r'^restcms/', include('restcms.urls')),
+
+3. Run `python manage.py migrate` to create the restcms models.
+
+4. Start the development server and visit http://127.0.0.1:8000/admin/
+   to create a poll (you'll need the Admin app enabled).
+
+5. Visit http://127.0.0.1:8000/restcms/ to participate in the poll.

restcms/__init__.py

Empty file added.
+from django.contrib import admin
+
+import reversion
+
+from .models import Page, File
+
+
+class PageAdmin(reversion.VersionAdmin):
+    list_display = [
+        'pk',
+        'title',
+        'language',
+        'path',
+        'status',
+        'publish_date',
+    ]
+
+
+class FileAdmin(admin.ModelAdmin):
+    list_display = [
+        'pk',
+        'download_url',
+        'created',
+    ]
+
+
+admin.site.register(Page, PageAdmin)
+admin.site.register(File, FileAdmin)
+from django import forms
+
+from .models import Page
+
+
+class PageForm(forms.ModelForm):
+
+    class Meta:
+        model = Page
+        fields = ["content", "language", "path"]
+        widgets = {
+            "language": forms.HiddenInput(),
+            "path": forms.HiddenInput(),
+        }

restcms/managers.py

+from django.db import models
+from django.utils import timezone
+
+
+class PublishedPageManager(models.Manager):
+
+    def get_query_set(self):
+        qs = super(PublishedPageManager, self).get_query_set()
+        return qs.filter(publish_date__lte=timezone.now(), status=self.model.PUBLIC)

restcms/models.py

+import os
+import re
+
+from django.conf import settings
+from django.core.urlresolvers import reverse
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.utils.translation import ugettext_lazy as _
+from django.utils import timezone
+from django.utils.encoding import force_bytes
+
+import reversion
+
+from .managers import PublishedPageManager
+
+
+class Page(models.Model):
+    """
+    >>> from django.conf import settings
+    >>> lang = settings.LANGUAGES[0][0]
+    >>> _ = Page.objects.create(path="path1", content="content", language=lang)
+
+    Page is unique under path and language.
+    >>> Page.objects.create(path="path1", content="content", language=lang)
+    Traceback (most recent call last):
+        ...
+    IntegrityError: UNIQUE constraint failed: restcms_page.path, restcms_page.language
+
+    So when one of them is different, its acceptable.
+    >>> _ = Page.objects.create(path="path1", content="content", language=lang + "_2")
+    >>> _ = Page.objects.create(path="path2", content="content", language=lang)
+
+    The path value must not be started with "/" and be ends with "/".
+    >>> Page(path="/path", content="content", language=lang).full_clean()
+    Traceback (most recent call last):
+        ...
+    ValidationError: {'path': ...}
+    """
+
+    DRAFT = 1
+    PUBLIC = 2
+    REJECT = 3
+    STATUS_CHOICES = (
+        (DRAFT, _(u"Draft")),
+        (PUBLIC, _(u"Public")),
+        (REJECT, _(u"Reject")),
+    )
+
+    PATH_RE = getattr(settings, 'RESTCMS_PAGE_PATH_REGEX', r"(([\w-]{1,})(/[\w-]{1,})*)/")
+
+    path = models.CharField(max_length=100)
+    content = models.TextField()
+    language = models.CharField(max_length=100, choices=settings.LANGUAGES)
+    status = models.IntegerField(choices=STATUS_CHOICES, default=DRAFT)
+    publish_date = models.DateTimeField(null=True, blank=True)
+    created = models.DateTimeField(auto_now_add=True)
+    updated = models.DateTimeField(auto_now=True)
+
+    objects = models.Manager()
+    published = PublishedPageManager()
+
+    class Meta:
+        unique_together = (("path", "language"),)
+
+    def __unicode__(self):
+        return '%s(%s)' % (self.title, self.language)
+
+    def __init__(self, *args, **kwargs):
+        super(Page, self).__init__(*args, **kwargs)
+        self._out = None
+
+    @property
+    def title(self):
+        if self._out is None:
+            self._render_content()
+        return self._out.get('title')
+
+    @property
+    def body(self):
+        if self._out is None:
+            self._render_content()
+        return self._out.get('body')
+
+    @property
+    def html_title(self):
+        if self._out is None:
+            self._render_content()
+        return self._out.get('html_title')
+
+    @property
+    def html_body(self):
+        if self._out is None:
+            self._render_content()
+        return self._out.get('html_body')
+
+    def _render_content(self):
+        try:
+            from docutils.core import publish_parts
+        except ImportError:
+            if settings.DEBUG:
+                raise IOError("The Python docutils library isn't installed.")
+            self._out = {}
+        else:
+            docutils_settings = getattr(settings, "RESTRUCTUREDTEXT_FILTER_SETTINGS", {})
+            self._out = publish_parts(source=force_bytes(self.content),
+                                      writer_name="html4css1",
+                                      settings_overrides=docutils_settings)
+
+    @models.permalink
+    def get_absolute_url(self):
+        return ("cms_page", [self.path])
+
+    @property
+    def is_community(self):
+        return self.path.lower().startswith("community/")
+
+    def publish(self):
+        self.status = Page.PUBLIC
+        self.full_clean()
+
+    def reject(self):
+        self.status = Page.REJECT
+        self.full_clean()
+
+    def clean_fields(self, exclude=None):
+        super(Page, self).clean_fields(exclude)
+        self._render_content()
+        self.validate_path()
+        self.update_publish_date()
+
+    def update_publish_date(self):
+        if self.status == Page.PUBLIC:
+            if self.publish_date is None:
+                self.publish_date = timezone.now()
+        else:
+            self.publish_date = None
+
+    def validate_path(self):
+        if not re.match(Page.PATH_RE, self.path):
+            raise ValidationError({"path": [_(u"Path can only contain letters, numbers and hyphens and end with /")]})
+
+    @classmethod
+    def guess_language(cls, language):
+        languages = dict(settings.LANGUAGES)
+        if language in languages:
+            return language
+        pos = language.find('-')
+        if pos > 0:
+            language = language[0:pos]
+            if language in languages:
+                return language
+        return None
+
+
+reversion.register(Page)
+
+
+def generate_filename(instance, filename):
+    return filename
+
+
+class File(models.Model):
+
+    file = models.FileField(upload_to=generate_filename)
+    created = models.DateTimeField(auto_now=True)
+
+    def download_url(self):
+        return reverse("file_download", args=[self.pk, os.path.basename(self.file.name).lower()])

restcms/templates/cms/page_detail.html

+{% extends "site_base.html" %}
+
+{% load i18n %}
+
+{% block page_title %}{{ page.title }}{% endblock %}
+
+{% block body %}
+    {{ page.html_title|safe }}
+    {% if editable %}
+        <div class="pull-right">
+            <a href="{% url 'cms_page_edit' page.path %}" class="btn btn-primary">
+                <i class="icon-pencil icon-large"></i> {% trans "Edit page" %}</a>
+        </div>
+    {% endif %}
+    {{ page.body|safe }}
+{% endblock %}

restcms/templates/cms/page_edit.html

+{% extends "site_base.html" %}
+
+{% load i18n %}
+{% load l10n %}
+
+{% block head_title %}{% trans "Create Page" %}{% endblock %}
+
+{% block body %}
+    <form method="POST" action="">
+        {% csrf_token %}
+        {{ form.as_p }}
+{% if form.instance.pk %}
+        <dl>
+            <dt>form.instance.language</dt>
+            <dd>{% trans form.instance.get_language_display %}</dd>
+            <dt>form.instance.status</dt>
+            <dd>{% trans form.instance.get_status_display %}</dd>
+            <dt>form.instance.created</dt>
+            <dd>{{ form.instance.created|localize }}</dd>
+        </dl>
+ {% endif %}
+        <div class="form-actions">
+            <input class="btn btn-primary" type="submit" value="{% trans "Save" %}" />
+        </div>
+    </form>
+{% endblock %}

restcms/templates/site_base.html

+<html>
+<head>
+<title>{% block page_title %}{% endblock %}</title>
+</head>
+<body>
+{% block body %}
+{% endblock %}
+</body>
+</html>
+import tempfile
+
+from django.core.urlresolvers import reverse
+from django.conf import settings
+from django.test import TestCase
+from django.test.utils import override_settings
+
+from .models import Page, File
+
+
+class PageEditorRoleMixin(object):
+    def loginAsPageEditor(self):
+        from django.contrib.auth.models import User, Permission
+
+        username = "user1"
+        password = "passwd"
+        user = User.objects.create_user(username, "foo@bar.com", password)
+        permission = Permission.objects.get_by_natural_key("change_page", "restcms", "page")
+        user.user_permissions.add(permission)
+
+        assert self.client.login(username=username, password=password)
+
+
+class PageMixin(object):
+    def create_page(self, content="content1", path="path/",
+                    language=settings.LANGUAGES[0][0], status=Page.DRAFT):
+        page = Page(content=content, path=path, language=language, status=status)
+        page.full_clean()
+        page.save()
+        return page
+
+
+class PageViewTest(TestCase, PageMixin, PageEditorRoleMixin):
+    def assertPageUsed(self, response, page):
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.context[-1]["page"], page)
+
+    def failIfPageEditable(self, response):
+        self.assertEqual(response.status_code, 200)
+        self.assertFalse(response.context[-1]["editable"])
+
+    def assertPageEditable(self, response):
+        self.assertEqual(response.status_code, 200)
+        self.assertTrue(response.context[-1]["editable"])
+
+    def test_it(self):
+        path = "foo/"
+        url = reverse("cms_page", kwargs={"path": path})
+
+        # no page.
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 404)
+
+        # not published yet.
+        page = self.create_page(path=path, status=Page.DRAFT)
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 404)
+
+        # render page when exists and published.
+        page.publish()
+        page.save()
+        response = self.client.get(url)
+        self.assertPageUsed(response, page)
+        self.assertTemplateUsed("cms/page_detail.html")
+
+        # rejected page can not be showed anymore.
+        page.reject()
+        page.save()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 404)
+
+    def test_prefer_language(self):
+        from django.conf import settings
+
+        path = "foo/"
+        url = reverse("cms_page", kwargs={"path": path})
+        lang = settings.LANGUAGES[0][0]
+
+        page_default = self.create_page(path=path, language=lang, status=Page.PUBLIC)
+
+        # no language specified, uses default.
+        response = self.client.get(url)
+        self.assertPageUsed(response, page_default)
+
+        # language specified but no page for the language, uses default.
+        self.client.cookies[settings.LANGUAGE_COOKIE_NAME] = "ja"
+        response = self.client.get(url)
+        self.assertPageUsed(response, page_default)
+
+        # language specified and the page for the language exits, uses one.
+        page_ja = self.create_page(path=path, language="ja", status=Page.DRAFT)
+        response = self.client.get(url)
+        self.assertPageUsed(response, page_default)
+
+        # once specified language page is published, shows it.
+        page_ja.publish()
+        page_ja.save()
+        response = self.client.get(url)
+        self.assertPageUsed(response, page_ja)
+
+        # after specified language page rejected, it fallback to default.
+        page_ja.reject()
+        page_ja.save()
+        response = self.client.get(url)
+        self.assertPageUsed(response, page_default)
+
+    def test_editable(self):
+        path = "foo/"
+        url = reverse("cms_page", kwargs={"path": path})
+
+        page = self.create_page(path=path, status=Page.PUBLIC)
+
+        response = self.client.get(url)
+        self.assertPageUsed(response, page)
+        self.failIfPageEditable(response)
+
+        self.loginAsPageEditor()
+        response = self.client.get(url)
+        self.assertPageUsed(response, page)
+        self.assertPageEditable(response)
+
+    def test_editable_for_not_found(self):
+        """
+        If logged-in user is editor but no page exists, redirect to create page on the path.
+        """
+        path = "not_found/"
+        view_url = reverse("cms_page", kwargs={"path": path})
+        edit_url = reverse("cms_page_edit", kwargs={"path": path})
+
+        self.loginAsPageEditor()
+        response = self.client.get(view_url)
+        self.assertRedirects(response, edit_url)
+
+    def test_render_rest(self):
+        path = "foo/"
+        url = reverse("cms_page", kwargs={"path": path})
+        page = self.create_page(path=path, content="""
+Hello
+=====
+
+This is a dummy.
+
+Sub section
+-----------
+
+Yeah.""", status=Page.PUBLIC)
+        response = self.client.get(url)
+        self.assertPageUsed(response, page)
+        self.assertContains(response, ('<p>This is a dummy.</p>\n'
+                                       '<div class="section" id="sub-section">\n'
+                                       '<h1>Sub section</h1>\n<p>Yeah.</p>\n'
+                                       '</div>\n'))
+
+
+class PageEditTest(TestCase, PageMixin, PageEditorRoleMixin):
+    def test_it(self):
+        path = "foo/"
+        url = reverse("cms_page_edit", kwargs={"path": path})
+
+        # anonymous, redirect to login.
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 302)
+        self.assertIn(settings.LOGIN_URL, response['Location'])
+
+        self.loginAsPageEditor()
+
+        # no page.
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, "cms/page_edit.html")
+
+        # page exists, shows edit page even draft.
+        page = self.create_page(content="content_foo", path=path, status=Page.DRAFT)
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, "cms/page_edit.html")
+        self.assertContains(response, 'content_foo')
+
+        # also it could be for rejected.
+        page.reject()
+        page.save()
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, "cms/page_edit.html")
+        self.assertContains(response, 'content_foo')
+
+    def test_edit(self):
+        path = "foo/"
+        language = settings.LANGUAGES[0][0]
+        view_url = reverse("cms_page", kwargs={"path": path})
+        edit_url = reverse("cms_page_edit", kwargs={"path": path})
+
+        self.loginAsPageEditor()
+
+        # visit for creation.
+        response = self.client.get(edit_url)
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(response, "cms/page_edit.html")
+
+        response = self.client.post(edit_url, data={
+            "content": "content1",
+            "path": path,
+            "language": language,
+        })
+        self.assertEqual(response.status_code, 302)
+        self.assertIn(view_url, response['Location'])
+
+        # once the page is published, can be showed to others.
+        page = Page.objects.get(path=path)
+        page.publish()
+        page.save()
+        response = self.client.get(view_url)
+        self.assertContains(response, "content1")
+        self.assertTemplateUsed(response, "cms/page_detail.html")
+
+
+class FileMixin(object):
+    def create_file(self, file, name=None):
+        from django.core.files import File as DjangoFile
+
+        return File.objects.create(file=DjangoFile(file, name=name))
+
+
+temp_MEDIA_ROOT = tempfile.mkdtemp()
+
+
+class FileDownloadTest(TestCase, FileMixin):
+    @classmethod
+    def tearDownClass(cls):
+        import shutil
+
+        shutil.rmtree(temp_MEDIA_ROOT)
+
+    @override_settings(MEDIA_ROOT=temp_MEDIA_ROOT)
+    def test_it(self):
+        from StringIO import StringIO
+
+        f = self.create_file(StringIO("Hello"), "hello.txt")
+
+        url = reverse("file_download", args=(f.pk, f.file.name))
+        response = self.client.get(url)
+        self.assertContains(response, "Hello")
+
+    @override_settings(MEDIA_ROOT=temp_MEDIA_ROOT)
+    def test_x_accel_redirect(self):
+        from StringIO import StringIO
+
+        f = self.create_file(StringIO("Hello"), "hello.txt")
+
+        url = reverse("file_download", args=(f.pk, f.file.name))
+        with self.settings(USE_X_ACCEL_REDIRECT=True):
+            response = self.client.get(url)
+        self.assertEqual(response["X-Accel-Redirect"], f.file.url)
+from django.conf.urls import url, patterns
+from .models import Page
+
+
+urlpatterns = patterns("restcms.views",
+    url(r"^files/(\d+)/([^/]+)$", "file_download", name="file_download"),
+    url(r"^(?P<path>%s)_edit/$" % Page.PATH_RE, "page_edit", name="cms_page_edit"),
+    url(r"^(?P<path>%s)$" % Page.PATH_RE, "page_view", name="cms_page"),
+)
+from django.conf import settings
+from django.http import Http404, HttpResponse
+from django.shortcuts import render, redirect, get_object_or_404
+from django.views import static
+from django.contrib.auth.decorators import login_required
+
+from .models import Page, File
+from .forms import PageForm
+
+
+def can_edit(page, user):
+    if page and page.is_community:
+        return True
+    else:
+        return user.has_perm("restcms.change_page")
+
+
+def get_page(path, language, published=True):
+    key = {"path": path, "language": language}
+    if published:
+        objects = Page.published
+    else:
+        objects = Page.objects
+    try:
+        return objects.get(**key)
+    except Page.DoesNotExist:
+        # fallback just with path
+        key = {"path": path}
+        try:
+            return objects.filter(**key).all()[0]
+        except Page.DoesNotExist:
+            pass
+        return None
+
+
+def page_view(request, path):
+    page = get_page(path, Page.guess_language(request.LANGUAGE_CODE))
+
+    editable = can_edit(page, request.user)
+
+    if page is None:
+        if editable:
+            return redirect("cms_page_edit", path=path)
+        else:
+            raise Http404
+
+    return render(request, "cms/page_detail.html", {
+        "page": page,
+        "editable": editable,
+    })
+
+
+@login_required
+def page_edit(request, path):
+    language = Page.guess_language(request.LANGUAGE_CODE)
+    initial = {"path": path, "language": language}
+    page = get_page(published=False, **initial)
+
+    if not can_edit(page, request.user):
+        raise Http404
+
+    if request.method == "POST":
+        form = PageForm(request.POST, instance=page)
+        if form.is_valid():
+            page = form.save(commit=False)
+            page.path = path
+            page.language = language
+            page.save()
+            return redirect(page)
+    else:
+        form = PageForm(instance=page, initial=initial)
+
+    return render(request, "cms/page_edit.html", {
+        "form": form,
+    })
+
+
+def file_download(request, pk, *args):
+    file = get_object_or_404(File, pk=pk)
+
+    if getattr(settings, "USE_X_ACCEL_REDIRECT", False):
+        response = HttpResponse()
+        response["X-Accel-Redirect"] = file.file.url
+        # delete content-type to allow Gondor to determine the filetype and
+        # we definitely don't want Django's default :-)
+        del response["content-type"]
+    else:
+        # enable USE_X_ACCEL_REDIRECT for production if possible.
+        response = static.serve(request, file.file.name, document_root=settings.MEDIA_ROOT)
+
+    return response
+import os
+from setuptools import setup
+
+README = open(os.path.join(os.path.dirname(__file__), 'README.rst')).read()
+
+# allow setup.py to be run from any path
+os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir)))
+
+setup(
+    name='django-restcms',
+    version='0.1',
+    packages=['restcms'],
+    include_package_data=True,
+    license='BSD License',  # example license
+    description='A simple Django cms with reStructuredText.',
+    long_description=README,
+    url='http://www.example.com/',
+    author='Your Name',
+    author_email='yourname@example.com',
+    install_requires=[
+        'django-reversion==1.7',
+        'docutils==0.11',
+    ],
+    classifiers=[
+        'Environment :: Web Environment',
+        'Framework :: Django',
+        'Intended Audience :: Developers',
+        'License :: OSI Approved :: BSD License', # example license
+        'Operating System :: OS Independent',
+        'Programming Language :: Python',
+        # Replace these appropriately if you are stuck on Python 2.
+        #'Programming Language :: Python :: 3',
+        #'Programming Language :: Python :: 3.2',
+        #'Programming Language :: Python :: 3.3',
+        'Topic :: Internet :: WWW/HTTP',
+        'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
+    ],
+)