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_ORIGINS

A 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 page

Conteú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.

Captura de tela de 2025-06-09 05-55-20

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.

Captura de tela de 2025-06-09 09-53-05
Create a quote

O conhecimento nunca esgota a mente

Leonardo Da Vinci

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.