Розробка сайту на CMS Wagtail
Wagtail — Django CMS з багатим редактором Draftail, StreamField для гнучкого контенту та вбудованим headless API. Використовується крупними організаціями: NASA, Google, Torchbox, Mozilla. Добре підходить для складних editorial-проектів на Python-стеку.
Чому Wagtail, а не Django без CMS
Wagtail додає до Django:
- Admin UI з live preview, історією редакцій, workflow затвердження
- StreamField — блочний редактор як Matrix в Craft
- Routable Pages — користувацькі URL всередину сторінки без окремого view
- Images — вбудована обробка зображень з focal point
- Documents — управління файлами
- Snippets — переіспользуємі об'єкти не прив'язані до сторінки
- Search — повнотекстовий пошук через Elasticsearch або PostgreSQL
Архітектура
myproject/
├── myproject/
│ ├── settings/
│ │ ├── base.py
│ │ ├── dev.py
│ │ └── production.py
│ ├── urls.py
│ └── wsgi.py
├── home/ # стартове додаток
├── blog/ # додаток блога
│ ├── models.py
│ ├── migrations/
│ └── templates/
│ └── blog/
├── core/ # спільні компоненти
│ ├── models.py # AbstractPage, Snippet-класи
│ └── blocks.py # StreamField блоки
└── requirements/
├── base.txt
└── production.txt
Встановлення та налаштування
pip install wagtail
wagtail start myproject
cd myproject
python manage.py migrate
python manage.py createsuperuser
# settings/base.py — ключові налаштування
INSTALLED_APPS = [
'home',
'blog',
'core',
'search',
'wagtail.contrib.routable_page',
'wagtail.contrib.search_promotions',
'wagtail.contrib.settings',
'wagtail.embeds',
'wagtail.sites',
'wagtail.users',
'wagtail.snippets',
'wagtail.documents',
'wagtail.images',
'wagtail.search',
'wagtail.admin',
'wagtail',
'modelcluster',
'taggit',
'django.contrib.admin',
# ...
]
WAGTAIL_SITE_NAME = 'My Site'
WAGTAILIMAGES_IMAGE_MODEL = 'core.CustomImage' # користувацька модель зображень
WAGTAILADMIN_BASE_URL = 'https://mysite.com'
WAGTAIL_ENABLE_WHATS_NEW_BANNER = False
Базові Page Models
# blog/models.py
from django.db import models
from wagtail.models import Page, Orderable
from wagtail.fields import RichTextField, StreamField
from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel
from wagtail.search import index
from wagtail.images.blocks import ImageChooserBlock
from wagtailmetadata.models import MetadataPageMixin
from modelcluster.fields import ParentalKey, ParentalManyToManyField
from taggit.models import TaggedItemBase
class BlogIndexPage(Page):
intro = RichTextField(blank=True)
content_panels = Page.content_panels + [
FieldPanel('intro'),
]
subpage_types = ['blog.BlogPost']
def get_context(self, request):
context = super().get_context(request)
posts = BlogPost.objects.live().public().order_by('-first_published_at')
# Фільтрація за тегом
tag = request.GET.get('tag')
if tag:
posts = posts.filter(tags__slug=tag)
# Пагінація
from django.core.paginator import Paginator
paginator = Paginator(posts, 12)
page = request.GET.get('page')
context['posts'] = paginator.get_page(page)
return context
class BlogPostTag(TaggedItemBase):
content_object = ParentalKey(
'BlogPost',
related_name='tagged_items',
on_delete=models.CASCADE,
)
class BlogPost(MetadataPageMixin, Page):
date = models.DateField('Дата посту')
intro = models.CharField(max_length=250)
body = StreamField([
('heading', blocks.CharBlock(form_classname='title')),
('paragraph', blocks.RichTextBlock()),
('image', ImageChooserBlock()),
('quote', blocks.BlockQuoteBlock()),
('embed', EmbedBlock()),
('code', CodeBlock()),
('call_to_action', CTABlock()),
], use_json_field=True)
tags = ClusterTaggableManager(through=BlogPostTag, blank=True)
categories = ParentalManyToManyField('blog.BlogCategory', blank=True)
search_fields = Page.search_fields + [
index.SearchField('intro'),
index.SearchField('body'),
index.FilterField('date'),
]
content_panels = Page.content_panels + [
MultiFieldPanel([
FieldPanel('date'),
FieldPanel('tags'),
FieldPanel('categories', widget=forms.CheckboxSelectMultiple),
], heading='Інформація про блог'),
FieldPanel('intro'),
FieldPanel('body'),
InlinePanel('gallery_images', label='Зображення галереї'),
]
parent_page_types = ['blog.BlogIndexPage']
subpage_types = []
StreamField блоки
# core/blocks.py
from wagtail import blocks
from wagtail.images.blocks import ImageChooserBlock
from wagtail.embeds.blocks import EmbedBlock
class CTABlock(blocks.StructBlock):
heading = blocks.CharBlock()
text = blocks.RichTextBlock(required=False)
button_label = blocks.CharBlock()
button_url = blocks.URLBlock()
variant = blocks.ChoiceBlock(choices=[
('primary', 'Первинний'),
('secondary', 'Вторинний'),
('outline', 'Контур'),
])
class Meta:
template = 'blocks/cta.html'
icon = 'pick'
label = 'Заклик до дії'
class TwoColumnBlock(blocks.StructBlock):
left_column = blocks.StreamBlock([
('paragraph', blocks.RichTextBlock()),
('image', ImageChooserBlock()),
])
right_column = blocks.StreamBlock([
('paragraph', blocks.RichTextBlock()),
('image', ImageChooserBlock()),
])
class Meta:
template = 'blocks/two_column.html'
icon = 'grip'
class CodeBlock(blocks.StructBlock):
language = blocks.ChoiceBlock(choices=[
('python', 'Python'),
('javascript', 'JavaScript'),
('bash', 'Bash'),
('sql', 'SQL'),
])
code = blocks.TextBlock()
class Meta:
template = 'blocks/code.html'
icon = 'code'
Шаблони
<!-- templates/blog/blog_post.html -->
{% extends "base.html" %}
{% load wagtailcore_tags wagtailimages_tags %}
{% block content %}
<article>
<header>
<h1>{{ page.title }}</h1>
<time datetime="{{ page.date|date:'Y-m-d' }}">
{{ page.date|date:"d F Y" }}
</time>
</header>
{% if page.header_image %}
{% image page.header_image fill-1200x630-c100 as img %}
<figure>
<img src="{{ img.url }}" width="{{ img.width }}" height="{{ img.height }}" alt="{{ page.header_image.alt }}">
</figure>
{% endif %}
<div class="post-body">
{% for block in page.body %}
{% include_block block %}
{% endfor %}
</div>
<!-- Пов'язані пости -->
{% with siblings=page.get_siblings.live.public.order_by('-first_published_at')[:3] %}
{% if siblings %}
<aside class="related">
<h3>Читайте також</h3>
{% for post in siblings %}
{% include "blog/_post_card.html" with post=post %}
{% endfor %}
</aside>
{% endif %}
{% endwith %}
</article>
{% endblock %}
Snippets — переіспользуємий контент
# core/models.py
from wagtail.snippets.models import register_snippet
@register_snippet
class Testimonial(models.Model):
author_name = models.CharField(max_length=255)
company = models.CharField(max_length=255, blank=True)
text = models.TextField()
avatar = models.ForeignKey(
'wagtailimages.Image',
null=True, blank=True,
on_delete=models.SET_NULL,
related_name='+',
)
panels = [
FieldPanel('author_name'),
FieldPanel('company'),
FieldPanel('text'),
FieldPanel('avatar'),
]
def __str__(self):
return f"{self.author_name} ({self.company})"
Wagtail API (Headless)
# settings/base.py
INSTALLED_APPS += ['wagtail.api.v2']
# urls.py
from wagtail.api.v2.views import PagesAPIViewSet
from wagtail.api.v2.router import WagtailAPIRouter
from wagtail.images.api.v2.views import ImagesAPIViewSet
from wagtail.documents.api.v2.views import DocumentsAPIViewSet
api_router = WagtailAPIRouter('wagtailapi')
api_router.register_endpoint('pages', PagesAPIViewSet)
api_router.register_endpoint('images', ImagesAPIViewSet)
api_router.register_endpoint('documents', DocumentsAPIViewSet)
urlpatterns = [
path('api/v2/', api_router.urls),
# ...
]
Часові рамки розробки
| Етап | Час |
|---|---|
| Встановлення + налаштування + розгортання | 1–2 дні |
| Page Models (5–7 типів) | 3–5 днів |
| StreamField блоки (8–12 блоків) | 2–4 дні |
| HTML-шаблони | 4–8 днів |
| Snippets та допоміжний контент | 1–2 дні |
| Пошук, теги, фільтрація | 1–2 дні |
| Корпоративний сайт на Wagtail | 15–25 днів |







