Repository: saleor/saleor
Pull request: Static page creation from dashboard
close #1778 

### TODO:
- [x] Tests
- [x] Include public pages in sitemap
- [x] Static Page middleware
- [x] Dashboard permissions and tests for them
- [x] Models and migrations

### Pull Request Checklist

(Please keep this section. It will make maintainer's life easier.)

1. [x] Privileged views and APIs are guarded by proper permission checks.
1. [x] All visible strings are translated with proper context.
1. [x] All data-formatting is locale-aware (dates, numbers, and so on).
1. [x] Database queries are optimized and the number of queries is constant.
1. [x] The changes are tested.
1. [x] The code is documented (docstrings, project documentation).
1. [x] Python code quality checks pass: `pycodestyle`, `pydocstyle`, `pylint`.
1. [x] JavaScript code quality checks pass: `eslint`.


Problem statement:
Add static pages
We would like to have ability to add static pages editor in dashboard, which could be visible in storefront.
A proper model should consist of page title, content, visibility and maybe also predefined template for example contact form.  
Content should be rich text editor with ability to insert images.  
Pages could be available at `/pages/slug-of-page-title/`

Hints from issue discussion:

Commit message: Fix docs

Patch to review:
diff --git a/docs/architecture/page.rst b/docs/architecture/page.rst
new file mode 100644
index 00000000000..d9a992f8b63
--- /dev/null
+++ b/docs/architecture/page.rst
@@ -0,0 +1,41 @@
+Saleor Pages
+===================
+
+
+Setting up custom pages
+-----------------------
+
+You can set up your own pages such as "About us" or "Important Announcement!" in the Pages menu in dashboard.
+Note that if you are not an admin, you need to be in group with proper permissions.
+
+
+Providing an URL to your page in storefront
+-------------------------------------------
+
+If you want to add a link to recently created page in storefront, all you need to do is to put the following code:
+
+.. code-block:: html
+
+  <a href="{% url "page:details" slug="your-slug" %}">Text for the link</a>
+
+in the proper template.
+For example in default Saleor setup, in the footer there is a list of categories:
+
+.. code-block:: html
+
+  {% for category in categories %}
+    <li>
+      <a href="{{ category.get_absolute_url }}">
+        {{ category|capfirst }}
+      </a>
+    </li>
+  {% endfor %}
+
+Say, you want to add "Terms of Service" page with `terms-of-service` slug; you just need to add:
+
+.. code-block:: html
+
+  {% endfor %}
+  <li>
+    <a href="{% url "page:details" slug="terms-of-service" %}">ToS</a>
+  </li>
diff --git a/saleor/account/backends/facebook.py b/saleor/account/backends/facebook.py
index 408c0744c76..b372ac016e5 100644
--- a/saleor/account/backends/facebook.py
+++ b/saleor/account/backends/facebook.py
@@ -1,7 +1,7 @@
 from social_core.backends.facebook import FacebookOAuth2
 
-from ...site import AuthenticationBackends
 from . import BaseBackend
+from ...site import AuthenticationBackends
 
 
 class CustomFacebookOAuth2(BaseBackend, FacebookOAuth2):
diff --git a/saleor/account/backends/google.py b/saleor/account/backends/google.py
index 4c83a526873..6c8e205b957 100644
--- a/saleor/account/backends/google.py
+++ b/saleor/account/backends/google.py
@@ -1,7 +1,7 @@
 from social_core.backends.google import GoogleOAuth2
 
-from ...site import AuthenticationBackends
 from . import BaseBackend
+from ...site import AuthenticationBackends
 
 
 class CustomGoogleOAuth2(BaseBackend, GoogleOAuth2):
diff --git a/saleor/account/forms.py b/saleor/account/forms.py
index 8aa80263166..63cd65f72d0 100644
--- a/saleor/account/forms.py
+++ b/saleor/account/forms.py
@@ -4,9 +4,9 @@
 from django.utils.translation import pgettext, pgettext_lazy
 from phonenumbers.phonenumberutil import country_code_for_region
 
+from . import emails
 from ..account.models import User
 from .i18n import AddressMetaForm, get_address_form_class
-from . import emails
 
 
 def get_address_form(
diff --git a/saleor/account/views.py b/saleor/account/views.py
index 1dac365430f..7ed4dfdba21 100755
--- a/saleor/account/views.py
+++ b/saleor/account/views.py
@@ -11,8 +11,8 @@
 from ..cart.utils import find_and_assign_anonymous_cart
 from ..core.utils import get_paginator_items
 from .forms import (
-    ChangePasswordForm, get_address_form, LoginForm, logout_on_password_change,
-    PasswordResetForm, SignupForm)
+    ChangePasswordForm, LoginForm, PasswordResetForm, SignupForm,
+    get_address_form, logout_on_password_change)
 
 
 @find_and_assign_anonymous_cart()
diff --git a/saleor/checkout/core.py b/saleor/checkout/core.py
index b28952c76da..9b5ef078730 100644
--- a/saleor/checkout/core.py
+++ b/saleor/checkout/core.py
@@ -17,7 +17,7 @@
 from ..core.utils import ZERO_TAXED_MONEY
 from ..discount.models import NotApplicable, Voucher
 from ..discount.utils import (
-    increase_voucher_usage, get_voucher_discount_for_checkout)
+    get_voucher_discount_for_checkout, increase_voucher_usage)
 from ..order.models import Order
 from ..shipping.models import ANY_COUNTRY, ShippingMethodCountry
 
diff --git a/saleor/core/permissions.py b/saleor/core/permissions.py
index 01b6ef84635..dbbfd0b9e6d 100644
--- a/saleor/core/permissions.py
+++ b/saleor/core/permissions.py
@@ -3,6 +3,8 @@
 MODELS_PERMISSIONS = [
     'order.view_order',
     'order.edit_order',
+    'page.view_page',
+    'page.edit_page',
     'product.view_category',
     'product.edit_category',
     'product.view_product',
diff --git a/saleor/core/sitemaps.py b/saleor/core/sitemaps.py
index 7583755363a..9ebfd0c0e54 100644
--- a/saleor/core/sitemaps.py
+++ b/saleor/core/sitemaps.py
@@ -1,5 +1,6 @@
 from django.contrib.sitemaps import Sitemap
 
+from ..page.models import Page
 from ..product.models import Category, Product
 
 
@@ -16,6 +17,14 @@ def items(self):
         return categories.only('id', 'name', 'slug')
 
 
+class PageSitemap(Sitemap):
+
+    def items(self):
+        posts = Page.objects.public()
+        return posts.only('id', 'title', 'slug')
+
+
 sitemaps = {
     'categories': CategorySitemap,
-    'products': ProductSitemap}
+    'products': ProductSitemap,
+    'pages': PageSitemap}
diff --git a/saleor/core/templatetags/status.py b/saleor/core/templatetags/status.py
index 2c15ce59bd9..4e270aca9ae 100644
--- a/saleor/core/templatetags/status.py
+++ b/saleor/core/templatetags/status.py
@@ -1,3 +1,5 @@
+import datetime
+
 from django.template import Library
 from payments import PaymentStatus
 
@@ -48,3 +50,16 @@ def render_variant_availability_status(variant):
     else:
         label_cls = LABEL_DANGER
     return {'status': display, 'label_cls': label_cls}
+
+
+@register.inclusion_tag('dashboard/includes/_page_availability.html')
+def render_page_availability(page):
+    today = datetime.date.today()
+    is_published = (
+        page.is_visible and (
+            page.available_on is None or page.available_on <= today))
+    ctx = {'is_published': is_published, 'page': page}
+    if is_published:
+        label_cls = LABEL_SUCCESS
+        ctx.update({'label_cls': label_cls})
+    return ctx
diff --git a/saleor/core/utils/random_data.py b/saleor/core/utils/random_data.py
index ffc459be581..9a47c5a3374 100644
--- a/saleor/core/utils/random_data.py
+++ b/saleor/core/utils/random_data.py
@@ -13,6 +13,8 @@
 from payments import PaymentStatus
 from prices import Money, TaxedMoney
 
+from ...account.models import Address, User
+from ...account.utils import store_user_address
 from ...discount import DiscountValueType, VoucherType
 from ...discount.models import Sale, Voucher
 from ...order import GroupStatus
@@ -21,8 +23,6 @@
     AttributeChoiceValue, Category, Collection, Product, ProductAttribute,
     ProductImage, ProductType, ProductVariant, Stock, StockLocation)
 from ...shipping.models import ANY_COUNTRY, ShippingMethod
-from ...account.models import Address, User
-from ...account.utils import store_user_address
 
 fake = Factory.create()
 STOCK_LOCATION = 'default'
diff --git a/saleor/dashboard/customer/filters.py b/saleor/dashboard/customer/filters.py
index 3ad702b7ac4..fcd08e204cd 100644
--- a/saleor/dashboard/customer/filters.py
+++ b/saleor/dashboard/customer/filters.py
@@ -4,8 +4,8 @@
 from django_countries import countries
 from django_filters import CharFilter, ChoiceFilter, OrderingFilter
 
-from ...core.filters import SortedFilterSet
 from ...account.models import User
+from ...core.filters import SortedFilterSet
 
 SORT_BY_FIELDS = (
     ('email', 'email'),
diff --git a/saleor/dashboard/customer/views.py b/saleor/dashboard/customer/views.py
index 020c88aa3b0..11481a91cb7 100644
--- a/saleor/dashboard/customer/views.py
+++ b/saleor/dashboard/customer/views.py
@@ -6,8 +6,8 @@
 from django.template.response import TemplateResponse
 from django.utils.translation import pgettext_lazy
 
-from ...core.utils import get_paginator_items
 from ...account.models import User
+from ...core.utils import get_paginator_items
 from ..emails import send_set_password_email
 from ..views import staff_member_required
 from .filters import UserFilter
diff --git a/saleor/dashboard/graphql/api.py b/saleor/dashboard/graphql/api.py
index 02a307329b0..dcc9c67ef61 100644
--- a/saleor/dashboard/graphql/api.py
+++ b/saleor/dashboard/graphql/api.py
@@ -6,7 +6,7 @@
 from ...graphql.product.types import Category, resolve_categories
 from ...graphql.utils import get_node
 from .product.mutations import (
-    CategoryCreateMutation, CategoryUpdateMutation, CategoryDelete)
+    CategoryCreateMutation, CategoryDelete, CategoryUpdateMutation)
 
 
 class Query(graphene.ObjectType):
diff --git a/saleor/dashboard/graphql/mutations.py b/saleor/dashboard/graphql/mutations.py
index 4fd4a4d2696..78bd7f95e58 100644
--- a/saleor/dashboard/graphql/mutations.py
+++ b/saleor/dashboard/graphql/mutations.py
@@ -7,8 +7,8 @@
 from graphene_django.form_converter import convert_form_field
 from graphene_django.registry import get_global_registry
 
-from ...graphql.utils import get_node
 from ...graphql.core.types import Error
+from ...graphql.utils import get_node
 
 registry = get_global_registry()
 
diff --git a/saleor/dashboard/page/__init__.py b/saleor/dashboard/page/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/saleor/dashboard/page/filters.py b/saleor/dashboard/page/filters.py
new file mode 100644
index 00000000000..70c4bb61710
--- /dev/null
+++ b/saleor/dashboard/page/filters.py
@@ -0,0 +1,34 @@
+from django.utils.translation import npgettext, pgettext_lazy
+from django_filters import CharFilter, OrderingFilter
+
+from ...core.filters import SortedFilterSet
+from ...page.models import Page
+
+SORT_BY_FIELDS = {
+    'title': pgettext_lazy('Page list sorting option', 'title'),
+    'url': pgettext_lazy('Page list sorting option', 'url')}
+
+
+class PageFilter(SortedFilterSet):
+    title = CharFilter(
+        label=pgettext_lazy('Page list title filter label', 'Title'),
+        lookup_expr='icontains')
+    url = CharFilter(
+        label=pgettext_lazy('Page list url filter label', 'URL'),
+        lookup_expr='icontains')
+    sort_by = OrderingFilter(
+        label=pgettext_lazy('Page list sorting filter label', 'Sort by'),
+        fields=SORT_BY_FIELDS.keys(),
+        field_labels=SORT_BY_FIELDS)
+
+    class Meta:
+        model = Page
+        fields = []
+
+    def get_summary_message(self):
+        counter = self.qs.count()
+        return npgettext(
+            'Number of matching records in the dashboard page list',
+            'Found %(counter)d matching page',
+            'Found %(counter)d matching pages',
+            number=counter) % {'counter': counter}
diff --git a/saleor/dashboard/page/forms.py b/saleor/dashboard/page/forms.py
new file mode 100644
index 00000000000..b7bb5d15b94
--- /dev/null
+++ b/saleor/dashboard/page/forms.py
@@ -0,0 +1,29 @@
+from django import forms
+from django.utils.translation import pgettext_lazy
+
+from ...page.models import Page
+from ..product.forms import RichTextField
+
+
+class PageForm(forms.ModelForm):
+
+    class Meta:
+        model = Page
+        exclude = []
+        widgets = {
+            'slug': forms.TextInput(attrs={'placeholder': 'example-slug'})}
+        labels = {
+            'is_visible': pgettext_lazy(
+                'Visibility status indicator', 'Publish')}
+        help_texts = {
+            'slug': pgettext_lazy(
+                'Form field help text',
+                'Slug is being used to create page URL')}
+
+    content = RichTextField()
+
+    def clean_slug(self):
+        # Make sure slug is not being written to database with uppercase.
+        slug = self.cleaned_data.get('slug')
+        slug = slug.lower()
+        return slug
diff --git a/saleor/dashboard/page/urls.py b/saleor/dashboard/page/urls.py
new file mode 100644
index 00000000000..d6378a2b2c7
--- /dev/null
+++ b/saleor/dashboard/page/urls.py
@@ -0,0 +1,10 @@
+from django.conf.urls import url
+
+from . import views
+
+urlpatterns = [
+    url(r'^$', views.page_list, name='page-list'),
+    url(r'^add/$', views.page_add, name='page-add'),
+    url(r'^(?P<pk>[0-9]+)/$', views.page_detail, name='page-detail'),
+    url(r'^(?P<pk>[0-9]+)/update/$', views.page_update, name='page-update'),
+    url(r'^(?P<pk>[0-9]+)/delete/$', views.page_delete, name='page-delete')]
diff --git a/saleor/dashboard/page/views.py b/saleor/dashboard/page/views.py
new file mode 100644
index 00000000000..97eb280817b
--- /dev/null
+++ b/saleor/dashboard/page/views.py
@@ -0,0 +1,74 @@
+from django.conf import settings
+from django.contrib import messages
+from django.contrib.auth.decorators import permission_required
+from django.shortcuts import get_object_or_404, redirect
+from django.template.response import TemplateResponse
+from django.utils.translation import pgettext_lazy
+
+from ...core.utils import get_paginator_items
+from ...page.models import Page
+from ..views import staff_member_required
+from .filters import PageFilter
+from .forms import PageForm
+
+
+@staff_member_required
+@permission_required('page.view_page')
+def page_list(request):
+    pages = Page.objects.all()
+    pages_filter = PageFilter(request.GET, queryset=pages)
+    pages = get_paginator_items(
+        pages_filter.qs, settings.DASHBOARD_PAGINATE_BY,
+        request.GET.get('page'))
+    ctx = {
+        'pages': pages, 'filter_set': pages_filter,
+        'is_empty': not pages_filter.queryset.exists()}
+    return TemplateResponse(request, 'dashboard/page/list.html', ctx)
+
+
+@staff_member_required
+@permission_required('page.edit_page')
+def page_update(request, pk):
+    page = get_object_or_404(Page, pk=pk)
+    return _page_edit(request, page)
+
+
+@staff_member_required
+@permission_required('page.edit_page')
+def page_add(request):
+    page = Page()
+    return _page_edit(request, page)
+
+
+def _page_edit(request, page):
+    form = PageForm(request.POST or None, instance=page)
+    if form.is_valid():
+        form.save()
+        msg = pgettext_lazy('Dashboard message', 'Saved page')
+        messages.success(request, msg)
+        return redirect('dashboard:page-detail', pk=page.pk)
+    ctx = {
+        'page': page, 'form': form}
+    return TemplateResponse(request, 'dashboard/page/form.html', ctx)
+
+
+@staff_member_required
+@permission_required('page.edit_page')
+def page_delete(request, pk):
+    page = get_object_or_404(Page, pk=pk)
+    if request.POST:
+        page.delete()
+        msg = pgettext_lazy(
+            'Dashboard message', 'Removed page %s') % (page.title,)
+        messages.success(request, msg)
+        return redirect('dashboard:page-list')
+    ctx = {'page': page}
+    return TemplateResponse(request, 'dashboard/page/modal_delete.html', ctx)
+
+
+@staff_member_required
+@permission_required('page.view_page')
+def page_detail(request, pk):
+    page = get_object_or_404(Page, pk=pk)
+    ctx = {'page': page}
+    return TemplateResponse(request, 'dashboard/page/detail.html', ctx)
diff --git a/saleor/dashboard/staff/views.py b/saleor/dashboard/staff/views.py
index 38f2bf5fcb5..bab3dcc695b 100644
--- a/saleor/dashboard/staff/views.py
+++ b/saleor/dashboard/staff/views.py
@@ -5,8 +5,8 @@
 from django.template.response import TemplateResponse
 from django.utils.translation import pgettext_lazy
 
-from ...core.utils import get_paginator_items
 from ...account.models import User
+from ...core.utils import get_paginator_items
 from ..emails import (
     send_promote_customer_to_staff_email, send_set_password_email)
 from ..views import staff_member_required
diff --git a/saleor/dashboard/urls.py b/saleor/dashboard/urls.py
index 54bf4ba2314..b8c1d9a6c15 100644
--- a/saleor/dashboard/urls.py
+++ b/saleor/dashboard/urls.py
@@ -10,6 +10,7 @@
 from .graphql.api import schema
 from .group.urls import urlpatterns as groups_urls
 from .order.urls import urlpatterns as order_urls
+from .page.urls import urlpatterns as page_urls
 from .product.urls import urlpatterns as product_urls
 from .search.urls import urlpatterns as search_urls
 from .shipping.urls import urlpatterns as shipping_urls
@@ -21,6 +22,7 @@
     url(r'^categories/', include(category_urls)),
     url(r'^collections/', include(collection_urls)),
     url(r'^orders/', include(order_urls)),
+    url(r'^page/', include(page_urls)),
     url(r'^products/', include(product_urls)),
     url(r'^customers/', include(customer_urls)),
     url(r'^staff/', include(staff_urls)),
diff --git a/saleor/dashboard/widgets.py b/saleor/dashboard/widgets.py
index 80e00d3407f..07df8ede1c6 100644
--- a/saleor/dashboard/widgets.py
+++ b/saleor/dashboard/widgets.py
@@ -4,8 +4,7 @@
 from django_filters.widgets import RangeWidget
 from django_prices.widgets import MoneyInput
 
-from ..account.widgets import (
-    PhonePrefixWidget as StorefrontPhonePrefixWidget)
+from ..account.widgets import PhonePrefixWidget as StorefrontPhonePrefixWidget
 
 
 class DateRangeWidget(RangeWidget):
diff --git a/saleor/discount/utils.py b/saleor/discount/utils.py
index eb48fb05c1a..f56bab3b612 100644
--- a/saleor/discount/utils.py
+++ b/saleor/discount/utils.py
@@ -4,11 +4,11 @@
 from django.utils.translation import pgettext
 from prices import Money, TaxedMoney
 
+from . import VoucherApplyToProduct, VoucherType
 from ..cart.utils import (
     get_category_variants_and_prices, get_product_variants_and_prices)
 from ..core.utils import ZERO_TAXED_MONEY
 from .models import NotApplicable
-from . import VoucherApplyToProduct, VoucherType
 
 
 def increase_voucher_usage(voucher):
diff --git a/saleor/graphql/api.py b/saleor/graphql/api.py
index 57d2c46ce92..3533a33045f 100644
--- a/saleor/graphql/api.py
+++ b/saleor/graphql/api.py
@@ -5,8 +5,8 @@
 from .core.filters import DistinctFilterSet
 from .product.filters import ProductFilterSet
 from .product.types import (
-    Category, ProductAttribute, Product, resolve_attributes, resolve_products,
-    resolve_categories)
+    Category, Product, ProductAttribute, resolve_attributes,
+    resolve_categories, resolve_products)
 from .utils import get_node
 
 
diff --git a/saleor/order/views.py b/saleor/order/views.py
index e36fcc8b21a..20a7b486414 100644
--- a/saleor/order/views.py
+++ b/saleor/order/views.py
@@ -10,14 +10,14 @@
 from django.utils.translation import pgettext_lazy
 from payments import PaymentStatus, RedirectNeeded
 
+from . import OrderStatus
+from ..account.forms import LoginForm
 from ..account.models import User
 from ..core.utils import get_client_ip
-from ..account.forms import LoginForm
 from .forms import (
     OrderNoteForm, PasswordForm, PaymentDeleteForm, PaymentMethodsForm)
 from .models import Order, OrderNote, Payment
 from .utils import attach_order_to_user, check_order_status
-from . import OrderStatus
 
 logger = logging.getLogger(__name__)
 
diff --git a/saleor/page/__init__.py b/saleor/page/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/saleor/page/migrations/0001_initial.py b/saleor/page/migrations/0001_initial.py
new file mode 100644
index 00000000000..5cd16362ed6
--- /dev/null
+++ b/saleor/page/migrations/0001_initial.py
@@ -0,0 +1,30 @@
+# Generated by Django 2.0.2 on 2018-03-02 01:50
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Page',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('slug', models.SlugField(max_length=100, unique=True)),
+                ('title', models.CharField(max_length=200)),
+                ('content', models.TextField()),
+                ('created', models.DateTimeField(auto_now_add=True)),
+                ('is_visible', models.BooleanField(default=False)),
+                ('available_on', models.DateField(blank=True, null=True)),
+            ],
+            options={
+                'ordering': ('slug',),
+                'permissions': (('view_page', 'Can view pages'), ('edit_page', 'Can edit pages')),
+            },
+        ),
+    ]
diff --git a/saleor/page/migrations/__init__.py b/saleor/page/migrations/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/saleor/page/models.py b/saleor/page/models.py
new file mode 100644
index 00000000000..392e809dfe5
--- /dev/null
+++ b/saleor/page/models.py
@@ -0,0 +1,44 @@
+import datetime
+
+from django.db import models
+from django.db.models import Q
+from django.urls import reverse
+from django.utils.translation import pgettext_lazy
+
+from ..core.utils import build_absolute_uri
+
+
+class PageQuerySet(models.QuerySet):
+    def public(self):
+        today = datetime.date.today()
+        return self.filter(
+            Q(is_visible=True),
+            Q(available_on__lte=today) | Q(available_on__isnull=True))
+
+
+class Page(models.Model):
+    slug = models.SlugField(unique=True, max_length=100)
+    title = models.CharField(max_length=200)
+    content = models.TextField()
+    created = models.DateTimeField(auto_now_add=True)
+    is_visible = models.BooleanField(default=False)
+    available_on = models.DateField(blank=True, null=True)
+
+    objects = PageQuerySet.as_manager()
+
+    class Meta:
+        ordering = ('slug',)
+        permissions = (
+            ('view_page',
+             pgettext_lazy('Permission description', 'Can view pages')),
+            ('edit_page',
+             pgettext_lazy('Permission description', 'Can edit pages')))
+
+    def __str__(self):
+        return self.title
+
+    def get_absolute_url(self):
+        return reverse('page:details', kwargs={'slug': self.slug})
+
+    def get_full_url(self):
+        return build_absolute_uri(self.get_absolute_url())
diff --git a/saleor/page/urls.py b/saleor/page/urls.py
new file mode 100644
index 00000000000..789bdc33fbf
--- /dev/null
+++ b/saleor/page/urls.py
@@ -0,0 +1,7 @@
+from django.conf.urls import url
+
+from . import views
+
+urlpatterns = [
+    url(r'^(?P<slug>[a-z0-9-_]+?)/$',
+        views.page_detail, name='details')]
diff --git a/saleor/page/utils.py b/saleor/page/utils.py
new file mode 100644
index 00000000000..d7718b62d64
--- /dev/null
+++ b/saleor/page/utils.py
@@ -0,0 +1,7 @@
+from .models import Page
+
+
+def pages_visible_to_user(user):
+    if user.is_authenticated and user.is_active and user.is_staff:
+        return Page.objects.all()
+    return Page.objects.public()
diff --git a/saleor/page/views.py b/saleor/page/views.py
new file mode 100644
index 00000000000..0b6ba87799c
--- /dev/null
+++ b/saleor/page/views.py
@@ -0,0 +1,17 @@
+import datetime
+
+from django.shortcuts import get_object_or_404
+from django.template.response import TemplateResponse
+
+from .utils import pages_visible_to_user
+
+
+def page_detail(request, slug):
+    page = get_object_or_404(
+        pages_visible_to_user(user=request.user).filter(slug=slug))
+    today = datetime.date.today()
+    is_visible = (
+        page.available_on is None or page.available_on <= today)
+    return TemplateResponse(
+        request, 'page/details.html', {
+            'page': page, 'is_visible': is_visible})
diff --git a/saleor/search/backends/postgresql_dashboard.py b/saleor/search/backends/postgresql_dashboard.py
index f5c7513789a..f0e27286915 100644
--- a/saleor/search/backends/postgresql_dashboard.py
+++ b/saleor/search/backends/postgresql_dashboard.py
@@ -1,9 +1,9 @@
 from django.contrib.postgres.search import (
     SearchQuery, SearchRank, SearchVector)
 
+from ...account.models import User
 from ...order.models import Order
 from ...product.models import Product
-from ...account.models import User
 
 
 def search_products(phrase):
diff --git a/saleor/settings.py b/saleor/settings.py
index f6547950b92..9e5924c1221 100644
--- a/saleor/settings.py
+++ b/saleor/settings.py
@@ -168,6 +168,7 @@ def get_list(text):
     'saleor.search',
     'saleor.site',
     'saleor.data_feeds',
+    'saleor.page',
 
     # External apps
     'versatileimagefield',
diff --git a/saleor/urls.py b/saleor/urls.py
index 1bffdbc8883..086271b89f0 100644
--- a/saleor/urls.py
+++ b/saleor/urls.py
@@ -15,6 +15,7 @@
 from .data_feeds.urls import urlpatterns as feed_urls
 from .graphql.api import schema
 from .order.urls import urlpatterns as order_urls
+from .page.urls import urlpatterns as page_urls
 from .product.urls import urlpatterns as product_urls
 from .search.urls import urlpatterns as search_urls
 
@@ -31,6 +32,7 @@
         schema=schema, graphiql=settings.DEBUG), name='api'),
     url(r'^jsi18n/$', JavaScriptCatalog.as_view(), name='javascript-catalog'),
     url(r'^order/', include((order_urls, 'order'), namespace='order')),
+    url(r'^page/', include((page_urls, 'page'), namespace='page')),
     url(r'^products/',
         include((product_urls, 'product'), namespace='product')),
     url(r'^account/',
diff --git a/templates/dashboard/base.html b/templates/dashboard/base.html
index eb4e11e7df8..e3f96a791a5 100644
--- a/templates/dashboard/base.html
+++ b/templates/dashboard/base.html
@@ -236,6 +236,13 @@
                       </a>
                     </li>
                   {% endif %}
+                  {% if perms.page.view_page %}
+                    <li class="{% block menu_pages_class %}{% endblock %}">
+                      <a href="{% url 'dashboard:page-list' %}">
+                        {% trans "Pages" context "Dashboard static pages" %}
+                      </a>
+                    </li>
+                  {% endif %}
                 </ul>
               </li>
               <li class="side-nav-section" id="version-indicator">
diff --git a/templates/dashboard/includes/_page_availability.html b/templates/dashboard/includes/_page_availability.html
new file mode 100644
index 00000000000..92180fb2246
--- /dev/null
+++ b/templates/dashboard/includes/_page_availability.html
@@ -0,0 +1,21 @@
+{% load i18n %}
+
+{% if is_published %}
+  <div class="label label-{{ label_cls }}">
+    {% trans "Published" context "Page availability status" %}
+  </div>
+{% elif not page.is_visible %}
+  <div class="label">
+    {% trans "Hidden" context "Page availability status" %}
+  </div>
+{% elif page.available_on %}
+  <div class="label">
+    {% blocktrans with date=page.available_on context "Page availability status" %}
+      Hidden (will become visible on {{ date }})
+    {% endblocktrans %}
+  </div>
+{% else %}
+  <div class="label">
+    {% trans "Hidden" context "Page availability status" %}
+  </div>
+{% endif %}
diff --git a/templates/dashboard/page/detail.html b/templates/dashboard/page/detail.html
new file mode 100644
index 00000000000..69d32fbd264
--- /dev/null
+++ b/templates/dashboard/page/detail.html
@@ -0,0 +1,64 @@
+{% extends "dashboard/base.html" %}
+{% load i18n %}
+{% load mptt_tags %}
+{% load materializecss %}
+{% load staticfiles %}
+{% load status %}
+{% load utils %}
+
+{% block title %}{% trans "Pages" context "Pages list page title" %} -
+  {{ block.super }}{% endblock %}
+
+{% block body_class %}body-pages{% endblock %}
+
+{% block breadcrumbs %}
+  <ul class="breadcrumbs breadcrumbs--history">
+    <li>
+      <a href="{% url 'dashboard:page-list' %}" class="breadcrumb">
+        {% trans "Pages" context "Pages form breadcrumbs" %}
+      </a>
+    </li>
+    <li class="back-mobile">
+      <a href="{% url 'dashboard:page-list' %}">
+        <svg data-src="{% static 'dashboard/images/arrow-left.svg' %}" fill="#fff" width="20px" height="20px" />
+      </a>
+    </li>
+    <li>
+      <span class="breadcrumbs--ellipsed-item">
+        {{ page }}
+      </span>
+    </li>
+  </ul>
+{% endblock %}
+
+{% block menu_pages_class %}active{% endblock %}
+
+{% block content %}
+  <div class="row">
+    <div class="col s12 l9">
+      <div class="card">
+        <div class="card-content">
+          <span class="right">{% render_page_availability page %}</span>
+          <span class="card-title">{{ page }}<br></span>
+            <h4>
+              {% trans "Page URL" context "Page URL" %}
+            </h4>
+            {{ page.get_full_url }}
+        </div>
+        <div class="card-action">
+          {% if perms.page.edit_page %}
+            <a href="{% url 'dashboard:page-update' page.pk %}" class="btn-flat waves-effect">
+              {% trans "Edit page" context "Page action" %}
+            </a>
+            <a href="#base-modal" data-href="{% url 'dashboard:page-delete' pk=page.pk %}" class="modal-trigger-custom btn-flat waves-effect">
+              {% trans "Remove page" context "Page action" %}
+            </a>
+          {% endif %}
+          <a href="{% url "page:details" slug=page.slug %}" target="_blank" class="btn-flat waves-effect">
+            {% trans "View on site" context "Page action" %}
+          </a>
+        </div>
+      </div>
+    </div>
+  </div>
+{% endblock %}
diff --git a/templates/dashboard/page/form.html b/templates/dashboard/page/form.html
new file mode 100644
index 00000000000..78760b6417c
--- /dev/null
+++ b/templates/dashboard/page/form.html
@@ -0,0 +1,78 @@
+{% extends "dashboard/base.html" %}
+{% load i18n %}
+{% load materializecss %}
+{% load staticfiles %}
+
+{% block page_title %}{% if page.pk %}{{ page }}{% else %}{% trans 'Add new page' %}{% endif %}{{ block.super }}{% endblock %}
+
+{% block title %}{% if page.pk %}{{ page }}{% else %}{% trans 'Add new page' %}{% endif %} - {{ block.super }}{% endblock %}
+
+{% block body_class %}body-page{% endblock %}
+
+{% block header_menu %}
+  {% url "dashboard:page-list" as url %}
+  {% include "dashboard/includes/_back-link.html" with url=url %}
+{% endblock %}
+
+{% block breadcrumbs %}
+ <ul class="breadcrumbs breadcrumbs--history">
+   <li>
+     <a href="{% url 'dashboard:page-list' %}" class="breadcrumbs">{% trans "Pages" %}</a>
+   </li>
+   <li class="back-mobile">
+     <a href="{% url 'dashboard:page-list' %}" class="breadcrumb"><i class="material-icons">arrow_back</i></a>
+   </li>
+   <li><span class="breadcrumbs--ellipsed-item">
+     {% if page.pk %}{{ page }}{% else %}{% trans 'Add new page' %}{% endif %}</span>
+   </li>
+  </ul>
+{% endblock %}
+
+{% block header_extra %}
+  {% if page.pk %}
+  <a class="dropdown-button right" href="#" data-activates="page-delete" data-constrainwidth="false">
+    <svg data-src="{% static 'dashboard/images/more-vert.svg' %}" height="24" width="24" />
+  </a>
+  <ul id="page-delete" class="dropdown-content">
+    <li>
+      <a class="modal-trigger-custom" href="#base-modal" data-href="{% url 'dashboard:page-delete' pk=page.pk %}">
+        {% trans "Delete page" context "Page action" %}
+      </a>
+    </li>
+  </ul>
+  {% endif %}
+{% endblock %}
+
+{% block menu_pages_class %}active{% endblock %}
+
+{% block content %}
+  <form method="post" class="card">
+    {% csrf_token %}
+    <div class="row">
+      <div class="card-content">
+        <div class="col s12 l8">
+          {{ form.title|materializecss }}
+          {{ form.content|materializecss }}
+        </div>
+        <div class="col s12 l4">
+          {{ form.slug|materializecss }}
+          {{ form.available_on|materializecss }}
+          {{ form.is_visible|materializecss }}
+        </div>
+      </div>
+    </div>
+    <div class="row">
+      <div class="col s12">
+        <div class="card-action right-align">
+        {% if page.pk %}
+          <a href="{% url 'dashboard:page-list' %}" class="btn-flat">{% trans "Cancel" %}</a>
+          <button type="submit" class="btn">{% trans "Save" %}</button>
+        {% else %}
+          <a href="{% url 'dashboard:page-list' %}" class="btn-flat">{% trans "Cancel" %}</a>
+          <button type="submit" class="btn submit-stay">{% trans "Create" %}</button>
+        {% endif %}
+        </div>
+      </div>
+    </div>
+  </form>
+{% endblock %}
diff --git a/templates/dashboard/page/list.html b/templates/dashboard/page/list.html
new file mode 100644
index 00000000000..93ae5d38805
--- /dev/null
+++ b/templates/dashboard/page/list.html
@@ -0,0 +1,86 @@
+{% extends "dashboard/base.html" %}
+{% load i18n %}
+{% load staticfiles %}
+{% load status %}
+{% load utils %}
+
+{% block title %}
+  {% trans "Pages" context "Pages list page title"%} - {{ block.super }}
+{% endblock %}
+
+{% block body_class %}body-page{% endblock %}
+
+{% block header_extra %}
+  {% if perms.page.edit_page %}
+    <div class="btn-fab-fixed">
+      <a class="btn-fab-default waves-effect waves-light"
+       href="{% url 'dashboard:page-add' %}"
+       title="{% trans "Add page page" %}"><svg data-src="{% static "dashboard/images/add.svg" %}" />
+      </a>
+    </div>
+  {% endif %}
+{% endblock %}
+
+{% block breadcrumbs %}
+  <ul class="breadcrumbs">
+    <li>{% trans "Pages" %}</li>
+  </ul>
+{% endblock %}
+
+{% block content %}
+  <div class="row">
+    <div class="col s12 l9">
+      {% if pages %}
+        <div class="card">
+          <div class="data-table-container">
+            <table class="bordered highlight responsive data-table data-table-description">
+              <thead>
+                <tr>
+                  {% trans "Title" context "Static pages table header name" as label %}
+                  {% sorting_header 'title' label %}
+
+                  {% trans "URL" context "Static pages table header url" as label %}
+                  {% sorting_header 'url' label is_wide=True %}
+                  <th>
+                    {% trans "Visibility" context "Pages table header" %}
+                  </th>
+                </tr>
+              </thead>
+              <tbody>
+                {% for page in pages %}
+                  <tr data-action-go="{% url 'dashboard:page-detail' pk=page.pk %}">
+                    <td>
+                      {{ page.title }}
+                    </td>
+                    <td>
+                      {{ page.get_full_url }}
+                    </td>
+                    <td>
+                      {% render_page_availability page %}
+                    </td>
+                  </tr>
+                {% endfor %}
+              </tbody>
+            </table>
+          </div>
+        </div>
+        {% paginate pages %}
+      {% else %}
+        {% if is_empty %}
+          {% include 'dashboard/page/zero_page.html' %}
+        {% else %}
+          <div class="col s12">
+            <div class="not-found">
+              <p class="grey-text">
+                {% trans "No pages found." context "Empty pages list message" %}
+              </p>
+            </div>
+          </div>
+        {% endif %}
+       {% endif %}
+    </div>
+    <div class="col s12 l3" id="filters">
+      {% if not is_empty %}{% filters filter_set %}{% endif %}
+    </div>
+   </div>
+{% endblock %}
diff --git a/templates/dashboard/page/modal_delete.html b/templates/dashboard/page/modal_delete.html
new file mode 100644
index 00000000000..8628aaabb19
--- /dev/null
+++ b/templates/dashboard/page/modal_delete.html
@@ -0,0 +1,22 @@
+{% load i18n %}
+
+<form action="{% url 'dashboard:page-delete' pk=page.pk %}" method="post">
+    {% csrf_token %}
+    <div class="modal-content">
+        <div class="row">
+            <div class="col s12">
+                <h5>{% trans "Are you sure?" %}</h5>
+            </div>
+        </div>
+        <p>
+            {% blocktrans with page=page %}
+                Are you sure you want to delete the page {{ page }}?
+            {% endblocktrans %}
+        </p>
+    </div>
+
+    <div class="modal-footer">
+        <button type="submit" class="modal-action btn-flat blue-text">{% trans "Delete" %}</button>
+        <a href="#" class="modal-action modal-close btn-flat blue-text">{% trans "Cancel" %}</a>
+    </div>
+</form>
diff --git a/templates/dashboard/page/zero_page.html b/templates/dashboard/page/zero_page.html
new file mode 100644
index 00000000000..9cf595558ac
--- /dev/null
+++ b/templates/dashboard/page/zero_page.html
@@ -0,0 +1,12 @@
+{% extends 'dashboard/base_zero_page.html' %}
+{% load i18n %}
+{% load static %}
+
+{% block title %}{% trans "No pages found." %}{% endblock %}
+{% block content %}
+  {% static "dashboard/images/add.svg" as img_path %}
+  {% blocktrans %}
+    Pages allows to create custom static pages such as "About us" that will be displayed at particular url.<br>
+    You can create pages by using the <span class="btn-fab-presentation"><svg data-src="{{ img_path }}" /></span> button.
+  {% endblocktrans %}
+{% endblock %}
diff --git a/templates/page/details.html b/templates/page/details.html
new file mode 100644
index 00000000000..6dc039defec
--- /dev/null
+++ b/templates/page/details.html
@@ -0,0 +1,34 @@
+{% extends "base.html" %}
+{% load i18n %}
+{% load materializecss %}
+
+{% block title %}{{ page.title }} - {{ block.super }}{% endblock %}
+
+{% block breadcrumb %}
+  <ul class="breadcrumbs list-unstyled">
+    <li>
+      <a href="{% url 'home' %}">
+        {% trans "Home" context "Main navigation item" %}
+      </a>
+    </li>
+    {% if page %}
+      <li>
+        <a href="{{ page.get_absolute_url }}">{{ page.title }}</a>
+      </li>
+    {% endif %}
+  </ul>
+{% endblock breadcrumb %}
+
+{% block content %}
+  {% if not is_visible %}
+    <div class="alert alert-warning" role="alert">
+      {% blocktrans trimmed with date=page.available_on|date context "Page details text" %}
+        <strong>Warning!</strong>
+        You are previewing a page that will become visible on <strong>{{ date }}</strong>.
+      {% endblocktrans %}
+    </div>
+  {% endif %}
+  <div class="">
+    {{ page.content|safe }}
+  </div>
+{% endblock %}
diff --git a/tests/conftest.py b/tests/conftest.py
index a2293e87834..a72cad628ff 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -19,6 +19,7 @@
 from saleor.order import GroupStatus
 from saleor.order.models import DeliveryGroup, Order, OrderLine
 from saleor.order.utils import recalculate_order
+from saleor.page.models import Page
 from saleor.product.models import (
     AttributeChoiceValue, Category, Collection, Product, ProductAttribute,
     ProductImage, ProductType, ProductVariant, Stock, StockLocation)
@@ -641,5 +642,16 @@ def group_data(orders, status):
 
 @pytest.fixture
 def collection(db):
-    collection = Collection.objects.create(name='Collection', slug='collection')
+    collection = Collection.objects.create(
+        name='Collection', slug='collection')
     return collection
+
+
+@pytest.fixture
+def page(db):
+    data = {
+        'slug': 'test-url',
+        'title': 'Test page',
+        'content': 'test content'}
+    page = Page.objects.create(**data)
+    return page
diff --git a/tests/dashboard/test_page.py b/tests/dashboard/test_page.py
new file mode 100644
index 00000000000..2d4d1d67118
--- /dev/null
+++ b/tests/dashboard/test_page.py
@@ -0,0 +1,49 @@
+from django.urls import reverse
+
+
+def test_page_list(admin_client):
+    url = reverse('dashboard:page-list')
+
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+
+def test_page_edit(admin_client, page):
+    url = reverse('dashboard:page-update', args=[page.pk])
+
+    response = admin_client.get(url)
+    assert response.status_code == 200
+    data = {
+        'slug': 'changed-url',
+        'title': 'foo',
+        'content': 'bar',
+        'visible': True}
+
+    response = admin_client.post(url, data=data)
+    assert response.status_code == 302
+
+
+def test_page_add(admin_client):
+    url = reverse('dashboard:page-add')
+
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+    data = {
+        'slug': 'aaaa',
+        'title': 'foo',
+        'content': 'bar',
+        'visible': False}
+
+    response = admin_client.post(url, data=data)
+    assert response.status_code == 302
+
+
+def test_page_delete(admin_client, page):
+    url = reverse('dashboard:page-delete', args=[page.pk])
+
+    response = admin_client.get(url)
+    assert response.status_code == 200
+
+    response = admin_client.post(url, data={'a': 'b'})
+    assert response.status_code == 302
diff --git a/tests/test_discounts.py b/tests/test_discounts.py
index e45dcd07f5e..95f872db8a3 100644
--- a/tests/test_discounts.py
+++ b/tests/test_discounts.py
@@ -11,8 +11,8 @@
 from saleor.discount.forms import CheckoutDiscountForm
 from saleor.discount.models import NotApplicable, Sale, Voucher
 from saleor.discount.utils import (
-    decrease_voucher_usage, increase_voucher_usage,
-    get_product_discount_on_sale, get_voucher_discount_for_checkout)
+    decrease_voucher_usage, get_product_discount_on_sale,
+    get_voucher_discount_for_checkout, increase_voucher_usage)
 from saleor.product.models import Product, ProductVariant
 
 
diff --git a/tests/test_page.py b/tests/test_page.py
new file mode 100644
index 00000000000..f89ff579797
--- /dev/null
+++ b/tests/test_page.py
@@ -0,0 +1,16 @@
+def test_draft_page_detail(client, admin_client, page):
+    page_url = page.get_absolute_url()
+    response = client.get(page_url)
+    assert response.status_code == 404
+    response = admin_client.get(page_url)
+    assert response.status_code == 200
+
+
+def test_public_page_detail(client, admin_client, page):
+    page.is_visible = True
+    page.save()
+    page_url = page.get_absolute_url()
+    response = client.get(page_url)
+    assert response.status_code == 200
+    response = admin_client.get(page_url)
+    assert response.status_code == 200


Code review comments:
diff_hunk: @@ -0,0 +1,41 @@
+Saleor Pages
path: docs/architecture/page.rst
text: @user1:
Static pages

@user2:
Are "static pages" a good name? They are not really static (as you can edit them in the dashboard) and even if they were the name focuses on the means rather than a goal (what problems do they solve?).

diff_hunk: @@ -0,0 +1,41 @@
+Saleor Pages
+===================
+
+
+Setting up custom pages
+-----------------------
+
+You can set up your own pages such as "About us" or "Important Announcement!" in the Pages menu in dashboard.
+Note that if you are not an admin, you need to be in group with proper permissions.
+
+
+Providing an URL to your page in storefront
+-------------------------------------------
+
+If you want to add a link to recently created page in storefront, all you need to do is to put the following code:
+
+.. code-block:: html
+
+  <a href="{% url "page:details" slug="your-slug" %}">Text for the link</a>
+
+in the proper template.
+For example in default Saleor setup, in the footer there is a list of categories:
path: docs/architecture/page.rst
text: @user1:
The example above already explains how to add a link to a static page in the storefront. This whole example seems redundant. I think we could even improve the example above and instead of "your-slug" and "Text for the link" use real-life values like "terms-of-service" and "Terms of Service".