Tô animado para dividir mais com vocês sobre a criação do blog. Depois de tudo estar funcionando corretamente: back + front + infra, fico orgulhoso de ver o produto final e ter consciência de que todo o esforço até aqui, valeu a pena. Mesmo diante das dificuldades, consegui finalizar e, mesmo não possuindo muita experiência com desing, a aplicação ficou muito bonita.
Nesse post vou falar da model Post do blog, sobre como configurar corretamente a classe junto ao preview do Wagtail, permitindo acompanhar o Front-end ao editar a página no CMS. E, também, sobre como adicionar campos que compõe uma publicação como, por exemplo, imagens, código, entre outros elementos.
Django // Wagtail
Para acompanhar o conteúdo, é necessário que se tenha domínio dos conceitos básicos de Django, por isso, recomendo o tutorial oficial. Além disso, é importante entender os conceitos de uma API Rest, a comunicação entre Front-end e Back-end será feita através de requisições na API.
Já o Wagtail é um CMS que pode ser integrado a uma aplicação Django, com uma interface administrativa completa que tem suporte a funcionalidade como:
Sistema de páginas e hierarquias
Gestão de imagens e documentos
Mecanismo de busca integrada com ElasticSearch ou PostgresSQL
API nativa do framework para acessar página e recursos
E para embasamento, recomendo o blog do Michael Yin e o seu livro também, uma didática bem pratica e com bastante exemplos de código e implementações completas
Headless // CMS
Por padrão, o CMS já nos permite implementar interfaces usando html css e todo ecossistema de templates do Django, em uma solução que deixa juntos a camada de apresentação e dados. Recomendo veementemente optar por esse modelo de desenvolvimento. Ao separar o Front-end, a complexidade escala bastante e indico deixar a decisão de uma possível separação para o futuro, quando essa necessidade ficar clara.
Mesmo eu gostando de trabalhar com frameworks reativos, não recomendo o seu uso de maneira indiscriminada. O principal motivo que optei pelo uso foi pela prática e familiaridade.
Setup
Para adicionar suporte à visualização na interface do CMS é necessário instalar os seguintes pacotes:
1wagtail-headless-preview = "^0.8.0"
2django-cors-headers = "^4.7.0"O pacote wagtail-headless-preview vai auxiliar na criação de token para autorizar a visualização de itens não publicados. Por padrão, o Wagtail não retorna as páginas não publicadas e, se esse fosse o caso, seria de fato um problema.
Já o pacote django-cors-headers é utilizado para configurar corretamente as opções de CORS (Cross-Origin Resource Sharing), uma vez que vamos fazer a requisição de uma origin diferente do Back-end. Faça a revisão das variáveis no seu arquivo de configuração.
1ALLOWED_HOSTS
2CSRF_TRUSTED_ORIGINS
3CORS_ALLOWED_ORIGINSA Página de uma Publicação
Para definir uma nova página, precisamos criar a model referente à sua implemetação. Seguindo o CMS, criamos a herança a partir da classe Page. Ao compor a model com a Page do Wagtail, o CMS entende a definição de página e permite adicionar os recursos que fazem parte do item, como a edição na interface administrativa e uma serie de outros elementos.
A model foi dividia em dois arquivos diferentes: um para as páginas e outro para os campos que compõem o corpo do post. Com isso, diminuindo o arquivo e promovendo uma navegação mais objetiva e direta, além de evitar a necessidade de buscas em muitas definições.
1class PostPage(HeadlessPreviewMixin, Page):
2 header_image = models.ForeignKey(
3 "wagtailimages.Image",
4 null=True,
5 blank=True,
6 on_delete=models.SET_NULL,
7 related_name="+",
8 )
9 subtitle = models.CharField(max_length=255, default="Blog post subtitle")
10 description = models.CharField(
11 max_length=255,
12 default="Vici consequat justo enim. Venenatis eget adipiscing luctus lorem.",
13 )
14 body = StreamField(BodyBlock(), blank=True)
15 tags = ClusterTaggableManager(through="blog.PostPageTag", blank=True)
16
17 def get_preview_url(self, req=None, token=None):
18
19 if not token:
20 return None
21 base_url = settings.WAGTAIL_HEADLESS_PREVIEW.get("CLIENT_URLS", {}).get(
22 "default", "http://localhost:3000"
23 )
24 page_identifier = self.slug
25 locale = self.locale.language_code if hasattr(self, "locale") else "pt"
26 client_url = f"{base_url}/{locale}/blog/{page_identifier}/"
27 preview_url = f"{client_url}?token={token}"
28 return preview_url
29
30 class FormattedDateSerializer(serializers.Field):
31 def to_representation(self, value):
32 request = self.context.get("request", None)
33 lang = request.GET.get("locale", "en") if request else "en"
34
35 if lang == "pt":
36 locale.setlocale(locale.LC_TIME, "pt_BR.UTF-8")
37 return value.strftime("%B de %Y") # Formato brasileiro
38 locale.setlocale(locale.LC_TIME, "en_US.UTF-8")
39 return value.strftime("%B of %Y") # Formato americano
40
41 content_panels = Page.content_panels + [
42 FieldPanel("header_image"),
43 FieldPanel("subtitle"),
44 FieldPanel("description"),
45 InlinePanel("categories", label="category"),
46 # InlinePanel("post_pages", label="postpages"),
47 FieldPanel("tags"),
48 FieldPanel("body"),
49 ]
50 api_fields = [
51 APIField("title"),
52 APIField("body"),
53 APIField("subtitle"),
54 APIField("tags"),
55 APIField("description"),
56 APIField(
57 "header_image_src",
58 serializer=ImageRenditionField("fill-800x400", source="header_image"),
59 ),
60 APIField("first_published_at", serializer=FormattedDateSerializer()),
61 APIField(
62 "categories",
63 serializer=PostPageBlogCategoryAPISerializer(many=True),
64 ),
65 ]A linha que define a classe tem heranças importantes que devem ser adicionadas: PostPage(HeadlessPreviewMixin, Page). A partir da seguinte implementação da model e da configuração correta da sua rota(Ver a sessão de 'Rotas para acessos'), após as migrations serem atualizadas e o banco refletir o código atual, é possível retornar PostPages atráves de requisições na API . Para um embasamento mais completo, consulte a documentação para referência e features suportadas via requisição, entre elas: paginação, busca textual, filtros e campos retornados.
A função get_preview_url nos permite retornar ao endereço no qual a interface administrativa deve usar para acessar o Front-end da aplicação. Ao implementar a herança com a classe HeadlessPreviewMixin, o token de acesso pode ser recuperado via argumento da função de preview, sendo essa chave a responsável por autorizar o acesso de uma página não publicada.
Por padrão, o slug do post e os demais argumentos são passados para a URI da publicação de maneira a emular o uso original do blog e garantir que a rota correta seja acessada, porém, atráves do token e do tipo de página, já é possível acessar a publicação.
No momento vamos abstrair a definição e explicação do StreamField utilizado e focar na página e pré-visualização da matéria a ser publicada.
Rota para Acesso
Após a definição da model PostPage é preciso criar uma rota a fim de permitir a pré-visualização a partir do token de acesso. O código abaixo segue a implementação da documentação do pacote de visualização e estende a classe padrão PagesAPIViewSet do CMS adicionando o comportamento de busca, que permite a visualização a partir da chave. E para a visualização padrão de uma postagem, é definido uma rota a parte seguindo a API padrão do Wagtail.
1from wagtail.api.v2.views import PagesAPIViewSet
2
3
4class PagePreviewAPIViewSet(PagesAPIViewSet):
5 known_query_parameters = PagesAPIViewSet.known_query_parameters.union(
6 ["content_type", "token"]
7 )
8
9 def listing_view(self, request):
10 # Delegate to detail_view, specifically so there's no
11 # difference between serialization formats.
12 self.action = "detail_view"
13 return self.detail_view(request, 0)
14
15 def detail_view(self, request, pk):
16 page = self.get_object()
17 serializer = self.get_serializer(page)
18 return Response(serializer.data)
19
20 def get_object(self):
21 app_label, model = self.request.GET["content_type"].split(".")
22
23 content_type = ContentType.objects.get(app_label=app_label, model=model)
24 page_preview = PagePreview.objects.get(
25 content_type=content_type, token=self.request.GET["token"]
26 )
27 page = page_preview.as_page()
28
29 if page is None:
30 # Handle case where as_page() returns None
31 raise Http404("Cannot find page preview")
32 if not page.pk:
33 # fake primary key to stop API URL routing from complaining
34 page.pk = 0
35
36 return pageConteúdo do post
Já acompanhamos anteriormente como criar uma página e como permitir a sua edição no painel próprio. Podemos proceder e falar da implementação que abstraí no inicio, referente ao conteúdo da página e como permitir ao usuário compor um post e as informações necessárias adicionando os campos que julgar relevante para a publicação.

Para compor o corpo de uma publicação, faço o uso do StreamField que, a partir de blocos, permite adicionar uma lista com os componentes necessários. A imagem mostra os itens que podem ser adicionados no painel administrativo da aplicação . Por padrão, já temos a opção de escolher alguns blocks já implementados e nativos do CMS ou podemos criar os nossos próprios de acordo com a necessidade. Na implementação do campos de uma Quote, por exemplo, é criado um novo bloco.
1from wagtail.blocks import (
2 BooleanBlock,
3 StructBlock,
4 StreamBlock,
5 CharBlock,
6 RichTextBlock,
7 ListBlock,
8 TextBlock,
9)
10from wagtail.images.blocks import ImageChooserBlock
11from wagtail.fields import RichTextField, StreamField
12from django.db import models
13
14
15class CustomImageChooserBlock(ImageChooserBlock):
16 def get_api_representation(self, value, context=None):
17 if value:
18 return {
19 "id": value.id,
20 "title": value.title,
21 "url": value.get_rendition("original").url,
22 "thumbnail": value.get_rendition("fill-300x200").url,
23 "description": value.description,
24 }
25 return None
26
27
28class NoteBlock(StructBlock):
29 content = RichTextBlock(help_text="Conteúdo da nota")
30 type = CharBlock(
31 choices=[
32 ("info", "Informação"),
33 ("warning", "Aviso"),
34 ("danger", "Perigo"),
35 ("success", "Sucesso"),
36 ],
37 default="info",
38 help_text="Tipo de nota",
39 )
40
41 class Meta:
42 icon = "info"
43 label = "Nota"
44 block_api_representation = "expand"
45
46
47class TableBlock(StructBlock):
48 headers = ListBlock(CharBlock(), help_text="Cabeçalhos da tabela")
49 rows = ListBlock(
50 ListBlock(RichTextBlock(), min_num=1), help_text="Linhas da tabela"
51 )
52
53 class Meta:
54 icon = "table"
55 label = "Tabela"
56 block_api_representation = "expand"
57
58
59class CodeFileBlock(StructBlock):
60 filename = CharBlock(help_text="Name of the file")
61 language = CharBlock(
62 choices=[
63 ("bash", "Bash"),
64 ("django", "Django"),
65 ("dockerfile", "Dockerfile"),
66 ("pgsql", "Pgsql"),
67 ("shell", "Shell"),
68 ("python", "Python"),
69 ("makefile", "Makefile"),
70 ("javascript", "JavaScript"),
71 ("typescript", "TypeScript"),
72 ("css", "CSS"),
73 ("scss", "SCSS"),
74 ("json", "JSON"),
75 ("yaml", "YAML"),
76 ("markdown", "Markdown"),
77 ("rust", "Rust"),
78 ("go", "Go"),
79 ("dart", "Dart"),
80 ("shell", "Shell/Bash"),
81 ("sql", "SQL"),
82 ],
83 default="python",
84 )
85 code = TextBlock(help_text="Code content for the file")
86
87
88class NoteBlock(StructBlock):
89 content = RichTextBlock(help_text="Conteúdo da nota")
90 type = CharBlock(
91 choices=[
92 ("info", "Informação"),
93 ("warning", "Aviso"),
94 ("danger", "Perigo"),
95 ("success", "Sucesso"),
96 ],
97 default="info",
98 help_text="Tipo de nota",
99 )
100
101 class Meta:
102 icon = "info"
103 label = "Nota"
104 block_api_representation = "expand"
105
106
107class TableBlock(StructBlock):
108 headers = ListBlock(CharBlock(), help_text="Cabeçalhos da tabela")
109 rows = ListBlock(
110 ListBlock(RichTextBlock(), min_num=1), help_text="Linhas da tabela"
111 )
112
113 class Meta:
114 icon = "table"
115 label = "Tabela"
116 block_api_representation = "expand"
117
118
119class OrderedListBlock(StructBlock):
120 items = ListBlock(RichTextBlock(), help_text="Lista de items")
121
122 class Meta:
123 icon = "list-ol"
124 label = "Lista Ordenada"
125 block_api_representation = "expand"
126
127
128class UnorderedListBlock(StructBlock):
129 items = ListBlock(RichTextBlock(), help_text="Lista de items")
130
131 class Meta:
132 icon = "list-ul"
133 label = "Lista Não Ordenada"
134 block_api_representation = "expand"
135
136
137class CodeBlock(StructBlock):
138 files = ListBlock(CodeFileBlock(), help_text="Multiple code files")
139
140 class Meta:
141 icon = "code"
142 block_api_representation = "expand"
143
144
145class Image(StructBlock):
146 image = CustomImageChooserBlock()
147
148 class Meta:
149 icon = "image"
150 block_api_representation = "expand"
151
152
153class QuoteBlock(Image):
154 quote = RichTextBlock(help_text="Texto da citação")
155 attribution = CharBlock(required=False, help_text="Autor da citação")
156 role = CharBlock(required=False, help_text="Cargo ou função do autor")
157
158 class Meta:
159 icon = "openquote"
160 block_api_representation = "expand"
161
162
163class ImageText(Image):
164 reverse = BooleanBlock(required=False)
165 text = RichTextBlock()
166
167 class Meta:
168 block_api_representation = "expand"
169
170
171class EmbedBlock(StructBlock):
172 PROVIDER_CHOICES = [
173 ("youtube", "YouTube"),
174 ("vimeo", "Vimeo"),
175 ("instagram", "Instagram"),
176 ("twitter", "Twitter"),
177 ("tiktok", "TikTok"),
178 ("soundcloud", "SoundCloud"),
179 ("spotify", "Spotify"),
180 ("other", "Outro Site"),
181 ]
182
183 provider = CharBlock(
184 choices=PROVIDER_CHOICES, help_text="Selecione a plataforma de mídia"
185 )
186
187 embed_code = CharBlock(
188 required=False,
189 help_text="Código de incorporação fornecido pelo site (se disponível)",
190 )
191
192 url = CharBlock(help_text="URL completa do conteúdo a ser incorporado")
193 caption = CharBlock(required=False, help_text="Legenda opcional para o embed")
194
195 class Meta:
196 icon = "media"
197 label = "Incorporar Mídia"
198 block_api_representation = "expand"
199
200
201class BodyBlock(StreamBlock):
202 h1 = CharBlock()
203 h2 = CharBlock()
204 paragraph = RichTextBlock()
205 code = CodeBlock()
206 quote = QuoteBlock()
207 image = Image()
208 image_text = ImageText()
209 image_carousel = ListBlock(CustomImageChooserBlock())
210 thumbnail_gallery = ListBlock(CustomImageChooserBlock())
211 ordered_list = OrderedListBlock()
212 unordered_list = UnorderedListBlock()
213 note = NoteBlock()
214 table = TableBlock()
215 embed = EmbedBlock()Temos a opção de criar mais elementos e podemos fazê-lo a partir da implementação da herança do StructBlock e, inclusive, passar o ícone a ser apresentado no painel. Utilizando os campos nativos, crio o novo Block com os itens necessários e agora possuímos uma estrutura que melhor se adapta ao meu uso. No caso do Quote, que já pegamos por exemplo anteriormente, uso os seguintes campos que acho necessário: quote, attribution, role e image.

O conhecimento nunca esgota a mente
Leonardo Da Vinci

Conclusão
Em conclusão, para adicionar suporte à opção de edição headless da página, é necessário adicionar o pacote extra e fazer a sua configuração correta com a opção de cors e redirecionamento adaptado à sua aplicação.
Além disso, como criar os nossos tipos de elementos para compor o corpo de uma publicação, permitindo ao leitor acesso a diferentes elementos e aprimorando a sua experiência.
No próximo momento, vamos passar pelo Front-end e entender como alguns elementos criados são renderizados. Até mais! Fiquem a vontade para entrar em contato em qualquer um dos meios listados abaixo.
