~sirn/fanboi2

ef32200702134f92ab4891b5335ffc075bb6af78 — Kridsada Thanabulpong 4 years ago 777a283
Add page model and views.

Allow creation of custom pages that may be use to store static content
such as guidelines or deletion policies. There also exists an
"internal pages" that are used for site customization.

Currently two internal pages are available:

* global_css — custom CSS that applies to every page.
* global_appendix — area that appear on top of footer on every page.

Any number of pages may be created.
M CHANGES.rst => CHANGES.rst +1 -0
@@ 7,6 7,7 @@
- [Add] Overriding rules allowing board status to be overridden per IP address.
- [Add] Board can now be locked or archived.
- [Add] Board, topic and post will now create a history copy on change.
- [Add] Custom pages for guidelines or site customization ("internal pages").
- [Add] More random quotes.
- [Fix] Imgur album now no longer matched in thumbnail extractor.
- [Change] Rewrite all board templates.

M assets/app/stylesheets/app.scss => assets/app/stylesheets/app.scss +27 -0
@@ 64,6 64,33 @@ body {
    }
}

/* Site appendix
 * ------------------------------------------------------------------------ */

.appendix {
    border-bottom: 1px solid;
    font-size: $font-size-small;
    line-height: $line-height-content;
    padding: $spacing-vertical-large 0;
    text-align: center;

    ul {
        display: block;
        list-style: none;
        margin: 0 0 0 (-$spacing-horizontal-small);
        padding: 0;
    }

    li {
        display: block;
        margin: 0 0 0 $spacing-horizontal-small;
    }

    @media (min-width: $bound-tablet) {
        text-align: left;
    }
}

/* Site footer
 * ------------------------------------------------------------------------ */


M assets/app/stylesheets/themes/debug.scss => assets/app/stylesheets/themes/debug.scss +4 -0
@@ 15,6 15,10 @@
        background-color: #114422;
    }

    .appendix {
        background-color: #964f62;
    }

    .footer {
        background-color: #669933;
    }

M assets/app/stylesheets/themes/obsidian.scss => assets/app/stylesheets/themes/obsidian.scss +6 -0
@@ 42,6 42,12 @@ $color-text:             #cfcfcf;
        border-color: $color-brand;
    }

    .appendix {
        background-color: $color-gray-darker;
        border-color: $color-gray-darker;
        color: $color-gray-light;
    }

    .footer {
        color: $color-gray-light;
    }

M assets/app/stylesheets/themes/topaz.scss => assets/app/stylesheets/themes/topaz.scss +10 -0
@@ 44,6 44,16 @@ $color-text:             #333;
        border-color: $color-brand;
    }

    .appendix {
        background-color: $color-gray-lighter;
        border-color: $color-tint;
        color: $color-gray-dark;

        a {
            font-weight: bold;
        }
    }

    .footer {
        color: $color-gray-dark;
    }

M fanboi2/__init__.py => fanboi2/__init__.py +1 -0
@@ 169,6 169,7 @@ def main(global_config, **settings):  # pragma: no cover
    config.add_request_method(tagged_static_path)

    config.include('fanboi2.serializers')
    config.include('fanboi2.views.pages', route_prefix='/pages')
    config.include('fanboi2.views.api', route_prefix='/api')
    config.include('fanboi2.views.boards', route_prefix='/')
    config.add_static_view('static', 'static', cache_max_age=3600)

A fanboi2/helpers/__init__.py => fanboi2/helpers/__init__.py +0 -0
R fanboi2/formatters.py => fanboi2/helpers/formatters.py +20 -0
@@ 270,6 270,26 @@ def format_post(context, request, post, shorten=None):
    return Markup(text)


def format_page(context, request, page):
    """Format a :class:`fanboi2.models.Page` object content based on the
    formatter specified in such page.

    :param context: A :class:`mako.runtime.Context` object.
    :param request: A :class:`pyramid.request.Request` object.
    :param page: A :class:`fanboi2.models.Page` object.

    :type context: mako.runtime.Context or None
    :type request: pyramid.request.Request
    :type page: fanboi2.models.Page
    :rtype: Markup
    """
    if page.formatter == 'markdown':
        return format_markdown(context, request, page.body)
    elif page.formatter == 'html':
        return Markup(page.body)
    return Markup(html.escape(page.body))


def format_datetime(context, request, dt):
    """Format datetime into a human-readable format.


A fanboi2/helpers/partials.py => fanboi2/helpers/partials.py +50 -0
@@ 0,0 1,50 @@
from markupsafe import Markup
from fanboi2.helpers.formatters import format_markdown
from fanboi2.models import DBSession, Page


def _get_internal_page(slug):
    """Returns a content of internal page.

    :param slug: An internal page slug.
    :type slug: String
    :rtype: String or None
    """
    page = DBSession.query(Page).filter_by(
            namespace='internal',
            slug=slug).\
        first()
    if page:
        return page.body


def global_css(context, request):
    """Returns a string of inline global custom CSS for site-wide CSS override.
    This custom CSS is the content of ``internal:global_css`` page.

    :param context: A :class:`mako.runtime.Context` object.
    :param request: A :class:`pyramid.request.Request` object.

    :type context: mako.runtime.Context or None
    :type request: pyramid.request.Request
    :rtype: Markup or None
    """
    page = _get_internal_page('global_css')
    if page:
        return Markup(page)


def global_appendix(context, request):
    """Returns a HTML of global appendix content. This appendix content is the
    content of ``internal:global_appendix`` page.

    :param context: A :class:`mako.runtime.Context` object.
    :param request: A :class:`pyramid.request.Request` object.

    :type context: mako.runtime.Context or None
    :type request: pyramid.request.Request
    :rtype: Markup or None
    """
    page = _get_internal_page('global_appendix')
    if page:
        return format_markdown(context, request, page)

M fanboi2/models/__init__.py => fanboi2/models/__init__.py +1 -0
@@ 8,6 8,7 @@ from .board import Board
from .topic import Topic
from .topic_meta import TopicMeta
from .post import Post
from .page import Page
from .rule import Rule
from .rule_ban import RuleBan
from .rule_override import RuleOverride

A fanboi2/models/page.py => fanboi2/models/page.py +29 -0
@@ 0,0 1,29 @@
from sqlalchemy.sql import func
from sqlalchemy.sql.schema import Column, UniqueConstraint
from sqlalchemy.sql.sqltypes import Integer, DateTime, String, Text, Unicode
from ._base import Base, Versioned


INTERNAL_PAGES = (
    ('global_css', 'none'),
    ('global_appendix', 'markdown'),
)


class Page(Versioned, Base):
    """Model class for pages. This model is a basis for user-accessible
    content that are not part of the board itself, including individual pages,
    custom CSS or board guidelines.
    """

    __tablename__ = 'page'
    __table_args__ = (UniqueConstraint('namespace', 'slug'),)

    id = Column(Integer, primary_key=True)
    created_at = Column(DateTime(timezone=True), default=func.now())
    updated_at = Column(DateTime(timezone=True), onupdate=func.now())
    namespace = Column(String, nullable=False, default='public')
    title = Column(Unicode, nullable=False)
    slug = Column(String, nullable=False)
    body = Column(Text, nullable=False)
    formatter = Column(String, nullable=False, default='markdown')

M fanboi2/serializers.py => fanboi2/serializers.py +31 -4
@@ 1,6 1,6 @@
import datetime
import pytz
from fanboi2.formatters import format_post
from fanboi2.helpers.formatters import format_post, format_page


def _datetime_adapter(obj, request):


@@ 92,7 92,7 @@ def _post_serializer(obj, request):
    :type request: pyramid.request.Request
    :rtype: dict
    """
    result = {
    return {
        'type': 'post',
        'id': obj.id,
        'body': obj.body,


@@ 109,7 109,33 @@ def _post_serializer(obj, request):
            query=obj.number,
        ),
    }
    return result


def _page_serializer(obj, request):
    """Serialize :class:`fanboi2.models.Page` into a :type:`dict`.

    :param obj: A :class:`fanboi2.models.Page` object.
    :param request: A :class:`pyramid.request.Request` object.

    :type obj: fanboi2.models.Page
    :type request: pyramid.request.Request
    :rtype: dict
    """
    return {
        'type': 'page',
        'id': obj.id,
        'body': obj.body,
        'body_formatted': format_page(None, request, obj),
        'formatter': obj.formatter,
        'namespace': obj.namespace,
        'slug': obj.slug,
        'title': obj.title,
        'updated_at': obj.updated_at or obj.created_at,
        'path': request.route_path(
            'api_page',
            page=obj.slug,
        ),
    }


def _result_proxy_serializer(obj, request):


@@ 173,7 199,7 @@ def initialize_renderer():
    from celery.result import AsyncResult
    from pyramid.renderers import JSON
    from sqlalchemy.orm import Query
    from fanboi2.models import Board, Topic, Post
    from fanboi2.models import Board, Topic, Post, Page
    from fanboi2.errors import BaseError
    from fanboi2.tasks import ResultProxy
    json_renderer = JSON()


@@ 182,6 208,7 @@ def initialize_renderer():
    json_renderer.add_adapter(Board, _board_serializer)
    json_renderer.add_adapter(Topic, _topic_serializer)
    json_renderer.add_adapter(Post, _post_serializer)
    json_renderer.add_adapter(Page, _page_serializer)
    json_renderer.add_adapter(ResultProxy, _result_proxy_serializer)
    json_renderer.add_adapter(AsyncResult, _async_result_serializer)
    json_renderer.add_adapter(BaseError, _base_error_serializer)

M fanboi2/templates/api/_boards.mako => fanboi2/templates/api/_boards.mako +1 -1
@@ 1,4 1,4 @@
<%namespace name='formatters' module='fanboi2.formatters' />
<%namespace name='formatters' module='fanboi2.helpers.formatters' />
<div class="api-section" id="api-boards">
    <div class="api-request">
        <div class="container">

M fanboi2/templates/api/_other.mako => fanboi2/templates/api/_other.mako +1 -1
@@ 1,4 1,4 @@
<%namespace name='formatters' module='fanboi2.formatters' />
<%namespace name='formatters' module='fanboi2.helpers.formatters' />
<div class="api-section" id="api-task">
    <div class="api-request">
        <div class="container">

A fanboi2/templates/api/_pages.mako => fanboi2/templates/api/_pages.mako +181 -0
@@ 0,0 1,181 @@
<%namespace name='formatters' module='fanboi2.helpers.formatters' />
<div class="api-section" id="api-pages">
    <div class="api-request">
        <div class="container">
            <h2 class="api-request-title">Retrieving pages <span class="api-request-name">#api-pages</span></h2>
            <div class="api-request-endpoint"><span class="api-request-verb verb-get">GET</span> ${formatters.unquoted_path(request, 'api_pages')}</div>
            <div class="api-request-body">
                <p>Use this endpoint to retrieve a list of all pages. Usually these pages will contain static content, such as guidelines or help.
            </div>
        </div>
    </div>
    <div class="api-response">
        <div class="container">
            <h3 class="api-response-title">Response</h3>
            <div class="api-response-body">
                <p><code>Array</code> containing <a href="#api-page">#api-page</a>.</p>
            </div>
        </div>
    </div>
</div>

<div class="api-section" id="api-page">
    <div class="api-request">
        <div class="container">
            <h2 class="api-request-title">Retrieving a page <span class="api-request-name">#api-page</span></h2>
            <div class="api-request-endpoint"><span class="api-request-verb verb-get">GET</span> ${formatters.unquoted_path(request, 'api_page', page='{api-page.slug}')}</div>
            <div class="api-request-body">
                <p>Use this endpoint to retrieve any individual page.</p>
            </div>
        </div>
    </div>
    <div class="api-response">
        <div class="container">
            <h3 class="api-response-title">Response</h3>
            <div class="api-response-body">
                <table class="api-table inner">
                    <thead class="api-table-header">
                        <tr class="api-table-row">
                            <th class="api-table-item title">Field</th>
                            <th class="api-table-item title">Type</th>
                            <th class="api-table-item title">Description</th>
                        </tr>
                    </thead>
                    <tbody class="api-table-body">
                        <tr class="api-table-row">
                            <th class="api-table-item title">type</th>
                            <td class="api-table-item type">String</td>
                            <td class="api-table-item">
                                <p>The type of API object. The value is always "page".</p>
                                <pre class="codeblock">"type":"page"</pre>
                            </td>
                        </tr>
                        <tr class="api-table-row">
                            <th class="api-table-item title">id</th>
                            <td class="api-table-item type">Integer</td>
                            <td class="api-table-item">
                                <p>Internal ID for the page.</p>
                                <pre class="codeblock">"id":1</pre>
                            </td>
                        </tr>
                        <tr class="api-table-row">
                            <th class="api-table-item title">body</th>
                            <td class="api-table-item type">String</td>
                            <td class="api-table-item">
                                <p>The page body.</p>
                                <pre class="codeblock">"body":"..."</pre>
                            </td>
                        </tr>
                        <tr class="api-table-row">
                            <th class="api-table-item title">body_formatted</th>
                            <td class="api-table-item type">String</td>
                            <td class="api-table-item">
                                <p>Same as <strong>body</strong> but format it using specified <strong>formatter</strong>. Note that this field is meant to be rendered in a HTML viewer and will escape the body in case the formatter is "none". If the content will not be displayed in a HTML viewer in case the formatter is "none", <strong>body</strong> must be used instead.</p>
                                <pre class="codeblock">"body_formatted":"&lt;p&gt;&lt;em&gt;Hello, world&lt;/em&gt;&lt;/p&gt;\n"</pre>
                            </td>
                        </tr>
                        <tr class="api-table-row">
                            <th class="api-table-item title">formatter</th>
                            <td class="api-table-item type">String</td>
                            <td class="api-table-item">
                                <p>Name of a formatter to format this page's body. Available values are:</p>
                                <ul>
                                    <li><strong>markdown</strong> — render this page using Markdown formatter (see also <a href="http://misaka.61924.nl/">Misaka</a>).</li>
                                    <li><strong>html</strong> — render this page as HTML without any escaping.</li>
                                    <li><strong>none</strong> — render this page as text or escape HTML as appropriate.</li>
                                </ul>
                                <pre class="codeblock">"formatter":"markdown"</pre>
                            </td>
                        </tr>
                        <tr class="api-table-row">
                            <th class="api-table-item title">namespace</th>
                            <td class="api-table-item type">String</td>
                            <td class="api-table-item">
                                <p>Namespace of this page, usually "public".</p>
                                <pre class="codeblock">"namespace":"public"</pre>
                            </td>
                        </tr>
                        <tr class="api-table-row">
                            <th class="api-table-item title">slug</th>
                            <td class="api-table-item type">String</td>
                            <td class="api-table-item">
                                <p>The identity of this page.</p>
                                <pre class="codeblock">"slug":"hello"</pre>
                            </td>
                        </tr>
                        <tr class="api-table-row">
                            <th class="api-table-item title">title</th>
                            <td class="api-table-item type">String</td>
                            <td class="api-table-item">
                                <p>The title of this page.</p>
                                <pre class="codeblock">"title":"Hello, world!"</pre>
                            </td>
                        </tr>
                        <tr class="api-table-row">
                            <th class="api-table-item title">updated_at</th>
                            <td class="api-table-item type">String</td>
                            <td class="api-table-item">
                                <p><a href="http://en.wikipedia.org/wiki/ISO_8601">ISO 8601</a>-formatted datetime of when the post was updated.</p>
                                <pre class="codeblock">"updated_at":"2016-10-29T14:59:12.451212-08:00"</pre>
                            </td>
                        </tr>
                        <tr class="api-table-row">
                            <th class="api-table-item title">path</th>
                            <td class="api-table-item type">String</td>
                            <td class="api-table-item">
                                <p>The path to this resource.</p>
                                <pre class="codeblock">"page":"/api/1.0/pages/hello/"</pre>
                            </td>
                        </tr>
                    </tbody>
                </table>
            </div>
        </div>
    </div>
</div>

<div class="api-section" id="api-topic-posts-scoped">
    <div class="api-request">
        <div class="container">
            <h2 class="api-request-title">Retrieving posts associated to a topic with scope <span class="api-request-name">#api-topic-posts-scoped</span></h2>
            <div class="api-request-endpoint"><span class="api-request-verb verb-get">GET</span> ${formatters.unquoted_path(request, 'api_topic_posts_scoped', topic='{api-topic.id}', query='{query}')}</div>
            <div class="api-request-body">
                <p>Use this endpoint to scope posts with the given <em>queries</em>. The <em>query</em> could be one of the following:</p>
                <table class="api-table">
                    <thead class="api-table-header">
                        <tr class="api-table-row">
                            <th class="api-table-item title">Query</th>
                            <th class="api-table-item title">Description</th>
                        </tr>
                    </thead>
                    <tbody class="api-table-body">
                        <tr class="api-table-row">
                            <th class="api-table-item title">{n}</th>
                            <td class="api-table-item">Query a single post with <em>n</em> number.</td>
                        </tr>
                        <tr class="api-table-row">
                            <th class="api-table-item title">{n1}-{n2}</th>
                            <td class="api-table-item">Query posts from <em>n1</em> to <em>n2</em>. If <em>n1</em> or <em>n2</em> is not given, 0 and last post are assumed respectively.</td>
                        </tr>
                        <tr class="api-table-row">
                            <th class="api-table-item title">l{n}</th>
                            <td class="api-table-item">Query the last <em>n</em> posts, for example, <strong>l10</strong> will list the last 10 posts.</td>
                        </tr>
                        <tr class="api-table-row">
                            <th class="api-table-item title">recent</th>
                            <td class="api-table-item">Alias for <strong>l30</strong>.</td>
                        </tr>
                    </tbody>
                </table>
            </div>
        </div>
    </div>
    <div class="api-response">
        <div class="container">
            <h3 class="api-response-title">Response</h3>
            <div class="api-response-body">
                <p>Same as <a href="#api-topic-posts">#api-topic-posts</a>.</p>
            </div>
        </div>
    </div>
</div>
\ No newline at end of file

M fanboi2/templates/api/_posts.mako => fanboi2/templates/api/_posts.mako +4 -4
@@ 1,9 1,9 @@
<%namespace name='formatters' module='fanboi2.formatters' />
<%namespace name='formatters' module='fanboi2.helpers.formatters' />
<div class="api-section" id="api-topic-posts-new">
    <div class="api-request">
        <div class="container">
            <h2 class="api-request-title">Creating new post in a topic <span class="api-request-name">#api-topic-posts-new</span></h2>
            <div class="api-request-endpoint"><span class="api-request-verb verb-post">POST</span> ${formatters.unquoted_path(request, 'api_topic_posts', topic='{api_topic.id}')}</div>
            <div class="api-request-endpoint"><span class="api-request-verb verb-post">POST</span> ${formatters.unquoted_path(request, 'api_topic_posts', topic='{api-topic.id}')}</div>
            <div class="api-request-body">
                <p>Use this endpoint to create a new post in a specific topic (i.e. post a reply). Please note that this API will <em>enqueue</em> the post with the global posting queue and will not guarantee that the post will be successful. To retrieve the status of the post, please see <a href="#api-task">#api-task</a>.</p>
                <table class="api-table">


@@ 37,7 37,7 @@
    <div class="api-request">
        <div class="container">
            <h2 class="api-request-title">Retrieving posts associated to a topic <span class="api-request-name">#api-topic-posts</span></h2>
            <div class="api-request-endpoint"><span class="api-request-verb verb-get">GET</span> ${formatters.unquoted_path(request, 'api_topic_posts', topic='{api_topic.id}')}</div>
            <div class="api-request-endpoint"><span class="api-request-verb verb-get">GET</span> ${formatters.unquoted_path(request, 'api_topic_posts', topic='{api-topic.id}')}</div>
            <div class="api-request-body">
                <p>Use this endpoint to retrieve a list of posts associated to the specific topic. By default this API will returns all posts. For a more specific query scope, please see <a href="#api-topic-posts-scoped">#api-topic-posts-scoped</a>.</p>
            </div>


@@ 155,7 155,7 @@
    <div class="api-request">
        <div class="container">
            <h2 class="api-request-title">Retrieving posts associated to a topic with scope <span class="api-request-name">#api-topic-posts-scoped</span></h2>
            <div class="api-request-endpoint"><span class="api-request-verb verb-get">GET</span> ${formatters.unquoted_path(request, 'api_topic_posts_scoped', topic='{api_topic.id}', query='{query}')}</div>
            <div class="api-request-endpoint"><span class="api-request-verb verb-get">GET</span> ${formatters.unquoted_path(request, 'api_topic_posts_scoped', topic='{api-topic.id}', query='{query}')}</div>
            <div class="api-request-body">
                <p>Use this endpoint to scope posts with the given <em>queries</em>. The <em>query</em> could be one of the following:</p>
                <table class="api-table">

M fanboi2/templates/api/_topics.mako => fanboi2/templates/api/_topics.mako +4 -4
@@ 1,9 1,9 @@
<%namespace name='formatters' module='fanboi2.formatters' />
<%namespace name='formatters' module='fanboi2.helpers.formatters' />
<div class="api-section" id="api-board-topics-new">
    <div class="api-request">
        <div class="container">
            <h2 class="api-request-title">Creating new topic in a board <span class="api-request-name">#api-board-topics-new</span></h2>
            <div class="api-request-endpoint"><span class="api-request-verb verb-post">POST</span> ${formatters.unquoted_path(request, 'api_board_topics', board='{api_board.slug}')}</div>
            <div class="api-request-endpoint"><span class="api-request-verb verb-post">POST</span> ${formatters.unquoted_path(request, 'api_board_topics', board='{api-board.slug}')}</div>
            <div class="api-request-body">
                <p>Use this endpoint to create a new topic in a specific board. Please note that this API will <em>enqueue</em> the topic with the global posting queue and will not guarantee that the topic will be successfully posted. To retrieve the status of topic creation, please see <a href="#api-task">#api-task</a>.</p>
                <table class="api-table">


@@ 41,7 41,7 @@
    <div class="api-request">
        <div class="container">
            <h2 class="api-request-title">Retrieving topics associated to a board <span class="api-request-name">#api-board-topics</span></h2>
            <div class="api-request-endpoint"><span class="api-request-verb verb-get">GET</span> ${formatters.unquoted_path(request, 'api_board_topics', board='{api_board.slug}')}</div>
            <div class="api-request-endpoint"><span class="api-request-verb verb-get">GET</span> ${formatters.unquoted_path(request, 'api_board_topics', board='{api-board.slug}')}</div>
            <div class="api-request-body">
                <p>Use this endpoint to retrieve a list of topics associated to the specific board. By default this API will return the same data as board's "All topics" page which includes open topic and topic that are closed (locked and archived) within 1 week of last posted date. It is also possible to include recent posts with <em>query string</em> but doing so with this API is not recommended.</p>
                <table class="api-table">


@@ 75,7 75,7 @@
    <div class="api-request">
        <div class="container">
            <h2 class="api-request-title">Retrieving a topic <span class="api-request-name">#api-topic</span></h2>
            <div class="api-request-endpoint"><span class="api-request-verb verb-get">GET</span> ${formatters.unquoted_path(request, 'api_topic', topic='{api_topic.id}')}</div>
            <div class="api-request-endpoint"><span class="api-request-verb verb-get">GET</span> ${formatters.unquoted_path(request, 'api_topic', topic='{api-topic.id}')}</div>
            <div class="api-request-body">
                <p>Use this endpoint to retrieve any individual topic. This endpoint by default will not include any posts but it is possible to instruct the API to include them using <em>query string</em>. Posts retrieved as part of this API is limited to the recent 30 posts. For retrieving a full list of posts, see <a href="#api-topic-posts">#api-topic-posts</a> and <a href="#api-topic-posts-scoped">#api-topic-posts-scoped</a>.</p>
                <table class="api-table">

M fanboi2/templates/api/show.mako => fanboi2/templates/api/show.mako +1 -0
@@ 9,4 9,5 @@
<%include file='_boards.mako' />
<%include file='_topics.mako' />
<%include file='_posts.mako' />
<%include file='_pages.mako' />
<%include file='_other.mako' />

M fanboi2/templates/boards/_subheader.mako => fanboi2/templates/boards/_subheader.mako +1 -1
@@ 1,4 1,4 @@
<%namespace name="formatters" module="fanboi2.formatters" />
<%namespace name="formatters" module="fanboi2.helpers.formatters" />
<header class="subheader">
    <div class="container">
        <h2 class="subheader-title"><a href="${request.route_path('board', board=board.slug)}">${board.title}</a></h2>

M fanboi2/templates/boards/all.mako => fanboi2/templates/boards/all.mako +1 -1
@@ 1,4 1,4 @@
<%namespace name='formatters' module='fanboi2.formatters' />
<%namespace name='formatters' module='fanboi2.helpers.formatters' />
<%inherit file='../partials/_layout.mako' />
<%include file='_subheader.mako' />
<%def name='title()'>All topics - ${board.title}</%def>

M fanboi2/templates/boards/new.mako => fanboi2/templates/boards/new.mako +1 -1
@@ 1,4 1,4 @@
<%namespace name="formatters" module="fanboi2.formatters" />
<%namespace name="formatters" module="fanboi2.helpers.formatters" />
<%include file='_subheader.mako' />
<%inherit file='../partials/_layout.mako' />
<%def name='title()'>New topic - ${board.title}</%def>

M fanboi2/templates/boards/show.mako => fanboi2/templates/boards/show.mako +1 -1
@@ 1,4 1,4 @@
<%namespace name='formatters' module='fanboi2.formatters' />
<%namespace name='formatters' module='fanboi2.helpers.formatters' />
<%namespace name='post' file='../partials/_post.mako' />
<%include file='_subheader.mako' />
<%inherit file='../partials/_layout.mako' />

M fanboi2/templates/not_found.mako => fanboi2/templates/not_found.mako +1 -1
@@ 1,4 1,4 @@
<%namespace name='formatters' module='fanboi2.formatters' />
<%namespace name='formatters' module='fanboi2.helpers.formatters' />
<%inherit file='partials/_layout.mako' />
<%def name='title()'>Not Found</%def>
<header class="subheader">

A fanboi2/templates/pages/_subheader.mako => fanboi2/templates/pages/_subheader.mako +8 -0
@@ 0,0 1,8 @@
<%namespace name="formatters" module="fanboi2.helpers.formatters" />
<header class="subheader">
    <div class="container">
        <h2 class="subheader-title"><a href="${request.route_path('page', page=page.slug)}">${page.title}</a></h2>
        <div class="subheader-body lines">Updated <strong>${formatters.format_datetime(request, page.updated_at or page.created_at)}</strong></div>
        </div>
    </div>
</header>
\ No newline at end of file

A fanboi2/templates/pages/show.mako => fanboi2/templates/pages/show.mako +11 -0
@@ 0,0 1,11 @@
<%namespace name='formatters' module='fanboi2.helpers.formatters' />
<%include file='_subheader.mako' />
<%inherit file='../partials/_layout.mako' />
<%def name='title()'>${page.title}</%def>
<div class="sheet">
    <div class="container">
         <div class="sheet-body">
              ${formatters.format_page(request, page)}
         </div>
    </div>
</div>
\ No newline at end of file

M fanboi2/templates/partials/_layout.mako => fanboi2/templates/partials/_layout.mako +18 -1
@@ 1,4 1,5 @@
<%namespace name="formatters" module="fanboi2.formatters" />
<%namespace name="formatters" module="fanboi2.helpers.formatters" />
<%namespace name="partials" module="fanboi2.helpers.partials" />
<!DOCTYPE html>
<html>
<head>


@@ 19,6 20,13 @@
    % if hasattr(self, 'header'):
        ${self.header()}
    % endif

    <% global_css = partials.global_css(request) %>
    % if global_css:
        <style>
            ${global_css}
        </style>
    % endif
</head>
<body id="${request.route_name}" class="${formatters.user_theme(request)}"${' ' + self.body_args() if hasattr(self, 'body_args') else ''}>



@@ 30,6 38,15 @@

${self.body()}

<% global_appendix = partials.global_appendix(request) %>
% if global_appendix:
    <section class="appendix">
        <div class="container">
            ${global_appendix}
        </div>
    </section>
% endif

<footer class="footer">
    <div class="container">
        <div class="footer-lines" data-theme-selector="true">

M fanboi2/templates/partials/_post.mako => fanboi2/templates/partials/_post.mako +1 -1
@@ 1,4 1,4 @@
<%namespace name="formatters" module="fanboi2.formatters" />
<%namespace name="formatters" module="fanboi2.helpers.formatters" />
<%def name="render_posts(topic, posts, shorten=None)">
    % for post in posts:
        <div class="post">

M fanboi2/templates/root.mako => fanboi2/templates/root.mako +1 -1
@@ 1,4 1,4 @@
<%namespace name='formatters' module='fanboi2.formatters' />
<%namespace name='formatters' module='fanboi2.helpers.formatters' />
<%inherit file='partials/_layout.mako' />
<header class="subheader">
    <div class="container">

M fanboi2/templates/topics/_subheader.mako => fanboi2/templates/topics/_subheader.mako +1 -1
@@ 1,4 1,4 @@
<%namespace name="formatters" module="fanboi2.formatters" />
<%namespace name="formatters" module="fanboi2.helpers.formatters" />
<header class="subheader">
    <div class="container">
        <h2 class="subheader-title"><a href="${request.route_path('topic', board=board.slug, topic=topic.id)}">${topic.title}</a></h2>

M fanboi2/tests/__init__.py => fanboi2/tests/__init__.py +10 -0
@@ 77,6 77,10 @@ class _ModelInstanceSetup(object):
            kwargs['ip_address'] = '0.0.0.0'
        return Post(**kwargs)

    def _newPage(self, **kwargs):
        from fanboi2.models import Page
        return Page(**kwargs)

    def _newRule(self, **kwargs):
        from fanboi2.models import Rule
        return Rule(**kwargs)


@@ 113,6 117,12 @@ class _ModelInstanceSetup(object):
        DBSession.flush()
        return post

    def _makePage(self, **kwargs):
        page = self._newPage(**kwargs)
        DBSession.add(page)
        DBSession.flush()
        return page

    def _makeRule(self, **kwargs):
        rule = self._newRule(**kwargs)
        DBSession.add(rule)

R fanboi2/tests/test_formatters.py => fanboi2/tests/test_helpers.py +91 -21
@@ 3,6 3,49 @@ from fanboi2.tests import ModelMixin, RegistryMixin
from pyramid import testing


class TestPartials(ModelMixin, RegistryMixin, unittest.TestCase):

    def test_global_css(self):
        from fanboi2.helpers.partials import global_css
        from markupsafe import Markup
        request = self._makeRequest()
        self._makePage(
            body='body { color: #000; }',
            formatter='none',
            slug='global_css',
            namespace='internal',
            title='Global CSS')
        self.assertEqual(
            global_css(None, request),
            Markup('body { color: #000; }'))

    def test_global_css_not_found(self):
        from fanboi2.helpers.partials import global_css
        from markupsafe import Markup
        request = self._makeRequest()
        self.assertIsNone(global_css(None, request))

    def test_global_appendix(self):
        from fanboi2.helpers.partials import global_appendix
        from markupsafe import Markup
        request = self._makeRequest()
        self._makePage(
            body='* Hello',
            formatter='markdown',
            slug='global_appendix',
            namespace='internal',
            title='Global Appendix')
        self.assertEqual(
            global_appendix(None, request),
            Markup('<ul>\n<li>Hello</li>\n</ul>\n'))

    def test_global_appendix_not_found(self):
        from fanboi2.helpers.partials import global_appendix
        from markupsafe import Markup
        request = self._makeRequest()
        self.assertIsNone(global_appendix(None, request))


class TestFormatters(unittest.TestCase):

    def _makeRequest(self, **kwargs):


@@ 14,7 57,7 @@ class TestFormatters(unittest.TestCase):
        return testing.DummyRequest(**kwargs)

    def test_url_fix(self):
        from fanboi2.formatters import url_fix
        from fanboi2.helpers.formatters import url_fix
        tests = [
            ('http://example.com/',
             'http://example.com/'),


@@ 33,7 76,7 @@ class TestFormatters(unittest.TestCase):
            self.assertEqual(url_fix(source), target)

    def test_extract_thumbnail(self):
        from fanboi2.formatters import extract_thumbnail
        from fanboi2.helpers.formatters import extract_thumbnail
        text = """
        Inline page: http://imgur.com/image1
        Inline image: http://i.imgur.com/image2.jpg


@@ 62,7 105,7 @@ class TestFormatters(unittest.TestCase):
        ))

    def test_post_markup(self):
        from fanboi2.formatters import PostMarkup
        from fanboi2.helpers.formatters import PostMarkup
        from markupsafe import Markup
        markup = PostMarkup('<p>foo</p>')
        markup.shortened = True


@@ 73,7 116,7 @@ class TestFormatters(unittest.TestCase):
        self.assertEqual(len(markup), 3)

    def test_format_text(self):
        from fanboi2.formatters import format_text
        from fanboi2.helpers.formatters import format_text
        from markupsafe import Markup
        tests = [
            ('Hello, world!', '<p>Hello, world!</p>'),


@@ 91,7 134,7 @@ class TestFormatters(unittest.TestCase):
            self.assertEqual(format_text(source), Markup(target))

    def test_format_text_autolink(self):
        from fanboi2.formatters import format_text
        from fanboi2.helpers.formatters import format_text
        from markupsafe import Markup
        text = ('Hello from autolink:\n\n'
                'Boom: http://example.com/"<script>alert("Hi")</script><a\n'


@@ 117,8 160,8 @@ class TestFormatters(unittest.TestCase):
                   'https://www.example.com/test</a> foobar</p>'))

    def test_format_text_shorten(self):
        from fanboi2.formatters import format_text
        from fanboi2.formatters import PostMarkup
        from fanboi2.helpers.formatters import format_text
        from fanboi2.helpers.formatters import PostMarkup
        from markupsafe import Markup
        tests = (
            ('Hello, world!', '<p>Hello, world!</p>', 13, False),


@@ 134,7 177,7 @@ class TestFormatters(unittest.TestCase):
            self.assertEqual(result.shortened, shortened)

    def test_format_text_thumbnail(self):
        from fanboi2.formatters import format_text
        from fanboi2.helpers.formatters import format_text
        from markupsafe import Markup
        text = ("New product! https://imgur.com/foobar1\n\n"
                "http://i.imgur.com/foobar2.png\n"


@@ 167,7 210,7 @@ class TestFormatters(unittest.TestCase):
                   '</p>'))

    def test_format_markdown(self):
        from fanboi2.formatters import format_markdown
        from fanboi2.helpers.formatters import format_markdown
        from markupsafe import Markup
        request = self._makeRequest()
        tests = [


@@ 181,13 224,13 @@ class TestFormatters(unittest.TestCase):
                             Markup(target))

    def test_format_markdown_empty(self):
        from fanboi2.formatters import format_markdown
        from fanboi2.helpers.formatters import format_markdown
        request = self._makeRequest()
        self.assertIsNone(format_markdown(None, request, None))

    def test_format_datetime(self):
        from datetime import datetime, timezone
        from fanboi2.formatters import format_datetime
        from fanboi2.helpers.formatters import format_datetime
        request = self._makeRequest()
        d1 = datetime(2013, 1, 2, 0, 4, 1, 0, timezone.utc)
        d2 = datetime(2012, 12, 31, 16, 59, 59, 0, timezone.utc)


@@ 198,7 241,7 @@ class TestFormatters(unittest.TestCase):

    def test_format_isotime(self):
        from datetime import datetime, timezone, timedelta
        from fanboi2.formatters import format_isotime
        from fanboi2.helpers.formatters import format_isotime
        ict = timezone(timedelta(hours=7))
        request = self._makeRequest()
        d1 = datetime(2013, 1, 2, 7, 4, 1, 0, ict)


@@ 209,22 252,22 @@ class TestFormatters(unittest.TestCase):
                         "2012-12-31T16:59:59Z")

    def test_user_theme(self):
        from fanboi2.formatters import user_theme
        from fanboi2.helpers.formatters import user_theme
        request = self._makeRequest(cookies={'_theme': 'debug'})
        self.assertEqual(user_theme(None, request), 'theme-debug')

    def test_user_theme_empty(self):
        from fanboi2.formatters import user_theme
        from fanboi2.helpers.formatters import user_theme
        request = self._makeRequest()
        self.assertEqual(user_theme(None, request), 'theme-topaz')

    def test_user_theme_invalid(self):
        from fanboi2.formatters import user_theme
        from fanboi2.helpers.formatters import user_theme
        request = self._makeRequest(cookies={'_theme': 'bogus'})
        self.assertEqual(user_theme(None, request), 'theme-topaz')

    def test_user_theme_alternative(self):
        from fanboi2.formatters import user_theme
        from fanboi2.helpers.formatters import user_theme
        request = self._makeRequest(cookies={'_foo': 'debug'})
        self.assertEqual(user_theme(None, request, '_foo'), 'theme-debug')



@@ 232,7 275,7 @@ class TestFormatters(unittest.TestCase):
class TestFormattersWithRegistry(RegistryMixin):

    def test_format_unquoted_path(self):
        from fanboi2.formatters import unquoted_path
        from fanboi2.helpers.formatters import unquoted_path
        request = self._makeRequest()
        config = self._makeConfig()
        config.add_route('board', '/test/{board}')


@@ 244,7 287,7 @@ class TestFormattersWithRegistry(RegistryMixin):
class TestFormattersWithModel(ModelMixin, RegistryMixin, unittest.TestCase):

    def test_format_post(self):
        from fanboi2.formatters import format_post
        from fanboi2.helpers.formatters import format_post
        from markupsafe import Markup
        request = self._makeRequest()
        config = self._makeConfig(request)


@@ 263,7 306,7 @@ class TestFormattersWithModel(ModelMixin, RegistryMixin, unittest.TestCase):
        post9 = self._makePost(topic=topic, body=">>>/demo/\n>>>/demo/1/")
        post10 = self._makePost(topic=topic, body=">>>/demo//100-/")
        post11 = self._makePost(topic=topic, body=">>>//123-/100-/")
        tests = [
        tests = (
            (post1, "<p>Hogehoge<br>Hogehoge</p>"),
            (post2, "<p><a data-anchor-topic=\"1\" " +
                    "data-anchor=\"1\" " +


@@ 314,12 357,12 @@ class TestFormattersWithModel(ModelMixin, RegistryMixin, unittest.TestCase):
                     "href=\"/demo\" " +
                     "class=\"anchor\">&gt;&gt;&gt;/demo/</a>/100-/</p>"),
            (post11, "<p>&gt;&gt;&gt;//123-/100-/</p>")
        ]
        )
        for source, target in tests:
            self.assertEqual(format_post(None, request, source), Markup(target))

    def test_format_post_shorten(self):
        from fanboi2.formatters import format_post
        from fanboi2.helpers.formatters import format_post
        from markupsafe import Markup
        request = self._makeRequest()
        config = self._makeConfig(request)


@@ 331,3 374,30 @@ class TestFormattersWithModel(ModelMixin, RegistryMixin, unittest.TestCase):
                         Markup("<p>Hello</p>\n<p class=\"shortened\">"
                                "Post shortened. <a href=\"/foobar/1/1-\" "
                                "class=\"anchor\">See full post</a>.</p>"))

    def test_format_page(self):
        from fanboi2.helpers.formatters import format_page
        from markupsafe import Markup
        request = self._makeRequest()
        page1 = self._makePage(
            body='**Markdown**',
            formatter='markdown',
            slug='page1',
            title='Page 1')
        page2 = self._makePage(
            body='<em>**HTML**</em>',
            formatter='html',
            slug='page2',
            title='Page 2')
        page3 = self._makePage(
            body='<em>**Plain**</em>',
            formatter='none',
            slug='page3',
            title='Page 3')
        tests = (
            (page1, '<p><strong>Markdown</strong></p>\n'),
            (page2, '<em>**HTML**</em>'),
            (page3, '&lt;em&gt;**Plain**&lt;/em&gt;'),
        )
        for source, target in tests:
            self.assertEqual(format_page(None, request, source), Markup(target))

M fanboi2/tests/test_models.py => fanboi2/tests/test_models.py +45 -0
@@ 1622,6 1622,51 @@ class TestPostModel(ModelMixin, unittest.TestCase):
        self.assertNotEqual(p1.ident, p2.ident)


class TestPageModel(ModelMixin, unittest.TestCase):

    def test_versioned(self):
        from fanboi2.models import Page
        PageHistory = Page.__history_mapper__.class_
        page = self._makePage(title='Foo', slug='foo', body='Foobar')
        self.assertEqual(page.version, 1)
        self.assertEqual(DBSession.query(PageHistory).count(), 0)
        page.body = 'Foobar baz updated'
        DBSession.add(page)
        DBSession.flush()
        self.assertEqual(page.version, 2)
        self.assertEqual(DBSession.query(PageHistory).count(), 1)
        page_v1 = DBSession.query(PageHistory).filter_by(version=1).one()
        self.assertEqual(page_v1.id, page.id)
        self.assertEqual(page_v1.title, 'Foo')
        self.assertEqual(page_v1.slug, 'foo')
        self.assertEqual(page_v1.body, 'Foobar')
        self.assertEqual(page_v1.version, 1)
        self.assertEqual(page_v1.change_type, 'update')
        self.assertIsNotNone(page_v1.changed_at)
        self.assertIsNotNone(page_v1.created_at)
        self.assertIsNone(page_v1.updated_at)

    def test_versioned_deleted(self):
        from sqlalchemy import inspect
        from fanboi2.models import Page
        PageHistory = Page.__history_mapper__.class_
        page = self._makePage(title='Foo', slug='foo', body='Foobar')
        DBSession.delete(page)
        DBSession.flush()
        self.assertTrue(inspect(page).deleted)
        self.assertEqual(DBSession.query(PageHistory).count(), 1)
        page_v1 = DBSession.query(PageHistory).filter_by(version=1).one()
        self.assertEqual(page_v1.id, page.id)
        self.assertEqual(page_v1.title, 'Foo')
        self.assertEqual(page_v1.slug, 'foo')
        self.assertEqual(page_v1.body, 'Foobar')
        self.assertEqual(page_v1.version, 1)
        self.assertEqual(page_v1.change_type, 'delete')
        self.assertIsNotNone(page_v1.changed_at)
        self.assertIsNotNone(page_v1.created_at)
        self.assertIsNone(page_v1.updated_at)


class TestRuleModel(ModelMixin, unittest.TestCase):

    def test_inheritance(self):

M fanboi2/tests/test_serializers.py => fanboi2/tests/test_serializers.py +17 -0
@@ 138,6 138,23 @@ class TestJSONRendererWithModel(ModelMixin, RegistryMixin, unittest.TestCase):
        self.assertIn('number', response)
        self.assertNotIn('ip_address', response)

    def test_page(self):
        page = self._makePage(title='Test', body='**Test**', slug='test')
        request = self._makeRequest()
        config = self._makeConfig(request, self._makeRegistry())
        config.add_route('api_page', '/page/{page}')
        response = self._makeOne(page, request=request)
        self.assertEqual(response['type'], 'page')
        self.assertEqual(response['body'], '**Test**')
        self.assertEqual(
            response['body_formatted'],
            '<p><strong>Test</strong></p>\n')
        self.assertEqual(response['formatter'], 'markdown')
        self.assertEqual(response['slug'], 'test')
        self.assertEqual(response['title'], 'Test')
        self.assertEqual(response['path'], '/page/test')
        self.assertIn('updated_at', response)


class TestJSONRendererWithTask(
        TaskMixin,

M fanboi2/tests/test_views.py => fanboi2/tests/test_views.py +75 -1
@@ 382,6 382,49 @@ class TestApiViews(ViewMixin, ModelMixin, TaskMixin, unittest.TestCase):
        self.assertTrue(time_.called)
        self.assertEqual(DBSession.query(Post).count(), post_count)

    def test_pages_get(self):
        from fanboi2.views.api import pages_get
        page1 = self._makePage(title='Foo', body='Foo', slug='foo')
        page2 = self._makePage(title='Bar', body='Bar', slug='bar')
        page3 = self._makePage(title='Baz', body='Baz', slug='baz')
        self._makePage(
            title='Hoge',
            body='Hoge',
            slug='hoge',
            namespace='internal')
        request = self._GET()
        response = list(pages_get(request))
        self.assertSAEqual(response, [page2, page3, page1])

    def test_page_get(self):
        from fanboi2.views.api import page_get
        page = self._makePage(title='Foo', body='Foo', slug='foo')
        request = self._GET()
        request.matchdict['page'] = page.slug
        response = page_get(request)
        self.assertSAEqual(response, page)

    def test_page_get_internal(self):
        from sqlalchemy.orm.exc import NoResultFound
        from fanboi2.views.api import page_get
        page = self._makePage(
            title='Foo',
            body='Foo',
            slug='foo',
            namespace='internal')
        request = self._GET()
        request.matchdict['page'] = page.slug
        with self.assertRaises(NoResultFound):
            page_get(request)

    def test_page_get_not_found(self):
        from sqlalchemy.orm.exc import NoResultFound
        from fanboi2.views.api import page_get
        request = self._GET()
        request.matchdict['page'] = 'notexists'
        with self.assertRaises(NoResultFound):
            page_get(request)

    def test_error_not_found(self):
        from pyramid.httpexceptions import HTTPNotFound
        from fanboi2.views.api import error_not_found


@@ 414,7 457,6 @@ class TestApiViews(ViewMixin, ModelMixin, TaskMixin, unittest.TestCase):
        self.assertFalse(_api_routes_only(None, request))



class TestBoardViews(ViewMixin, unittest.TestCase):

    def test_root(self):


@@ 1075,3 1117,35 @@ class TestBoardViews(ViewMixin, unittest.TestCase):
        config.testing_add_renderer('not_found.mako')
        response = error_not_found(HTTPNotFound(), request)
        self.assertEqual(response.status, '404 Not Found')


class TestPagesView(ViewMixin, unittest.TestCase):

    def test_page_show(self):
        from fanboi2.views.pages import page_show
        page = self._makePage(title='Hello', slug='hello', body='Hello')
        request = self._GET()
        request.matchdict['page'] = page.slug
        response = page_show(request)
        self.assertSAEqual(response['page'], page)

    def test_page_get_internal(self):
        from sqlalchemy.orm.exc import NoResultFound
        from fanboi2.views.pages import page_show
        page = self._makePage(
            title='Hello',
            body='Hello',
            slug='hello',
            namespace='internal')
        request = self._GET()
        request.matchdict['page'] = page.slug
        with self.assertRaises(NoResultFound):
            page_show(request)

    def test_page_show_not_found(self):
        from sqlalchemy.orm.exc import NoResultFound
        from fanboi2.views.pages import page_show
        request = self._GET()
        request.matchdict['page'] = 'notexists'
        with self.assertRaises(NoResultFound):
            page_show(request)

M fanboi2/views/api.py => fanboi2/views/api.py +36 -1
@@ 4,7 4,7 @@ from sqlalchemy.sql import or_, and_, select
from webob.multidict import MultiDict
from fanboi2.errors import ParamsInvalidError, RateLimitedError, BaseError
from fanboi2.forms import TopicForm, PostForm
from fanboi2.models import DBSession, Board, Topic, TopicMeta
from fanboi2.models import DBSession, Board, Topic, TopicMeta, Page
from fanboi2.tasks import ResultProxy, add_topic, add_post, celery
from fanboi2.utils import RateLimiter, serialize_request



@@ 184,6 184,38 @@ def topic_posts_post(request, board=None, topic=None, form=None):
    raise ParamsInvalidError(form.errors)


def pages_get(request, namespace=None):
    """Retrieve a list of all pages.

    :param request: A :class:`pyramid.request.Request` object.

    :type request: pyramid.request.Request
    :rtype: sqlalchemy.orm.Query
    """
    if namespace is None:
        namespace = 'public'
    return DBSession.query(Page).\
        order_by(Page.title).\
        filter_by(namespace=namespace)


def page_get(request, namespace=None):
    """Retrieve a page.

    :param request: A :class:`pyramid.request.Request` object.

    :type request: pyramid.request.Request
    :rtype: sqlalchemy.orm.Query
    """
    if namespace is None:
        namespace = 'public'
    return DBSession.query(Page).\
        filter_by(
            namespace=namespace,
            slug=request.matchdict['page']).\
        one()


def error_not_found(exc, request):
    """Handle any exception that should cause the app to treat it as
    NotFound resources, such as :class:`pyramid.httpexceptions.HTTPNotFound`


@@ 250,6 282,9 @@ def includeme(config):  # pragma: no cover
                    route_name=name,
                    renderer='json')

    _map_api_route('api_pages', '/1.0/pages/', {'GET': pages_get})
    _map_api_route('api_page', '/1.0/pages/{page:\w+}/', {'GET': page_get})

    _map_api_route('api_boards', '/1.0/boards/', {'GET': boards_get})
    _map_api_route('api_board', '/1.0/boards/{board:\w+}/', {'GET': board_get})
    _map_api_route('api_board_topics', '/1.0/boards/{board:\w+}/topics/', {

A fanboi2/views/pages.py => fanboi2/views/pages.py +22 -0
@@ 0,0 1,22 @@
from fanboi2.views.api import page_get


def page_show(request):
    """Display a single page.

    :param request: A :class:`pyramid.request.Request` object.

    :type request: pyramid.request.Request
    :rtype: dict
    """
    page = page_get(request)
    return locals()


def includeme(config):  # pragma: no cover
    config.add_route('page', '/{page:\w+}/')
    config.add_view(
        page_show,
        request_method='GET',
        route_name='page',
        renderer='pages/show.mako')

A migration/versions/7224deb8bfa9_create_page_table.py => migration/versions/7224deb8bfa9_create_page_table.py +49 -0
@@ 0,0 1,49 @@
"""create page table

Revision ID: 7224deb8bfa9
Revises: a6f20e3c63c2
Create Date: 2016-10-30 10:13:38.772224

"""

# revision identifiers, used by Alembic.
revision = '7224deb8bfa9'
down_revision = 'a6f20e3c63c2'

from alembic import op
import sqlalchemy as sa


def upgrade():
    op.create_table('page',
        sa.Column('id', sa.Integer(), nullable=False),
        sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
        sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
        sa.Column('namespace', sa.String(), nullable=False),
        sa.Column('title', sa.Unicode(), nullable=False),
        sa.Column('slug', sa.String(), nullable=False),
        sa.Column('body', sa.Text(), nullable=False),
        sa.Column('formatter', sa.String(), nullable=False),
        sa.Column('version', sa.Integer(), nullable=False),
        sa.PrimaryKeyConstraint('id'),
        sa.UniqueConstraint('namespace', 'slug'),
    )
    op.create_table('page_history',
        sa.Column('id', sa.Integer(), autoincrement=False, nullable=False),
        sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
        sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
        sa.Column('namespace', sa.String(), nullable=False),
        sa.Column('title', sa.Unicode(), nullable=False),
        sa.Column('slug', sa.String(), nullable=False),
        sa.Column('body', sa.Text(), nullable=False),
        sa.Column('formatter', sa.String(), nullable=False),
        sa.Column('version', sa.Integer(), autoincrement=False, nullable=False),
        sa.Column('change_type', sa.String(), nullable=False),
        sa.Column('changed_at', sa.DateTime(timezone=True), nullable=False),
        sa.PrimaryKeyConstraint('id', 'version'),
    )


def downgrade():
    op.drop_table('page_history')
    op.drop_table('page')