Un sitio personal con Wagtail ...

Después de casi dos décadas, decido actualizar mi sitio web personal utilizando medios propios y software opensource. Como siempre, una buena oportunidad para repasar conceptos.
Un poco de historia
Sobre 1995 este era el aspecto que tenía mi web, sólo html muy básico, algún gif animado y un par de cgis. Simple y ligero; tanto las redes como los sistemas de la época lo exigían.

El NCSA Mosaic de la época tampoco podía hacer mucho más cuando se trataba de renderizar tablas anidadas, y eso con el soporte de tablas activado; si, tenía una opción en el menú para ello. Recordemos que en 1.996 se publicó una extensión al estandard HTML2, definido en la RFC 1942. Con el netscape (navigator) la cosa comenzó a progresar, y nuevos elementos de html permitían maquetar algo mejor sin separar realmente los datos del la maquetación, además del soporte de los gifs animados, que le daban un poco de vida a las páginas siempre que no te pasases, claro.
No puedo decir donde estaba hospedado mi primer sitio, con su ftp de recursos por detrás, pero en aquella época había una cierta libertad imposible de imaginar ahora. Por aquel entonces uno accedía a la red como podía, desde la universidad, desde el trabajo, o mediante conexiones dial-up, que te daban 58kbps como mucho. Descargarse toda una distribución de linux slackware en tropecientos disquetes de 1.44M era toda una aventura. La aparición de las primeras tarifas planas te permitían estar un gran número de horas conectado, eso sí no podías utilizar el teléfono porque para la conexión se utilizaba la propia red telefónica conmutada.

Este es el aspecto que tenía mi página a finales del siglo pasado. Con la llegada de las conexiones ADSL la mayoría de operadores solían tener la opción de IP fija, cosa que facilitaba mucho el autohospedaje incuso del DNS, mediante el uso de pequeños ordenadores de poco consumo como los basados en el Intel Atom.
Desde entonces fueron muchos los motivos, en los que no voy a entrar, por los que este contenido fue quedando estático y obsoleto, casi como testigo arqueológico y punto final de una época a la que nunca volveremos: conexiones dialup, el uucp, usenet, las news, las bbs, los fancines de Legión y la islatortuga de Ángel .... viva el jamón y el vino.

La ventaja de usar solo html, permitía una buena visualización incluso en navegadores basados solo en terminal, como el lynx, hoy en día prácticamente imposible, aun teniendo un poco de cuidado, con el uso y abuso de javascript, css, DOM, ...
La renovación
El objetivo del proyecto es tener un sitio web que permita ser un punto de compartición de proyectos e ideas, galería básica de astrofotografía y portal de acceso a recursos online. Uno de los requisitos era que permitiese la localización (opcional) de las páginas creadas en varias lenguas y que fuese medianamente usable en todo tipo de dispositivos; bravo por el @screen
, flex
y grid
del css.
Después de darle muchas vueltas me decidí por wagtail
:
- Basado en django (
python
), e integrable en un entorno con otras aplicacionesdjango
ya existentes. - Muy orientado a desarrolladores, pero una vez configurado no requiere tocar nada para añadir contenido.
- Interfaz de administración personalizable
- Muchas contribuciones, extensiones, activo, y razonablemente actualizado
- Opciones de internacionalización y multilenguaje.
- Ligero
Para darle la funcionalidad que deseaba le añadi el módulo puput
, que automáticamente te da la funcionalidad de blog y, con mediante extensiones sin tocar las fuentes, la galería de astrofotografía.
Puntos clave
No voy a entrar en este post en describir paso por paso como está realizado este sitio, pero sí algunos aspectos clave o puntos importantes que me he encontrado durante el diseño y construcción. Hay multitud de tutoriales por la red de bastante calidad que expican desde como dar los primeros pasos con python
y django
, como diseñar y construir un entorno de producción wagtail
:
- Get started with Django
- Django Girls Tutorial
- Your first Wagtail Site
- Wagtail Tutorial Series por Michael Yin
- Learn Wagtail, con multitud de videos y otros recursos
- Sito web de Ria Parish , con una estética simple y muy elegante, donde podemos encontrar unos muy útiles consejos a quienes nos hemos aventurado en un proyecto similar.
StreamFields
Se trata de una forma elegante de generar estructuras de presentación complejas, con solo un inconveniente principal: las migraciones. Cuando cambias tu modelo de datos te ves obligado a construir una migración personalizada para poder transformar la estructura de datos antigua a la nueva, por ejemplo añadir un nuevo campo. El procedimiento de migración estándard de wagtail
no soporta la conversión de estas estructuras, y hay que hacerlo a mano generando una migración manual:
manage.py makemigrations --empty yourappname
y editar a conveniencia el fichero que genera en el directorio migrations/
de la aplicación afectada, para después lanzar la migración.
La buena noticia es que los datos del streamfield
están representados por una estructura json estándard. Estas estructuras son fáciles de manejar con python por medio de la representación del json mediante listas y diccionarios.
A continuación un ejemplo simple de migración para pasar de un tipo de block
a otro, que necesita un nuevo campo, haciendo uso de la recursividad para buscar y tratar la estructura del json.
import json
from importlib import import_module
from django.db import migrations, models
from django.core.serializers.json import DjangoJSONEncoder
def migrate_block(obj):
"""Recursively fetch values from nested JSON."""
def extract(obj):
global match
"""Recursively search for values of key in JSON tree."""
if isinstance(obj, dict):
if 'type' in obj.keys() and obj['type'] == 'image':
obj['type'] = 'image2'
obj['value'] = {'image_type':'center','aImage': obj['value']}
else:
for (k, v) in obj.items():
if isinstance(v, (dict, list)):
extract(v)
elif isinstance(obj, list):
for item in obj:
extract(item)
extract(obj)
def forwards(apps, schema_editor):
AboutPage = import_module('home.models').AboutPage
for page in AboutPage.objects.all():
page_data = json.loads(page.to_json())
content_data = json.loads(page_data['content'])
migrate_block(content_data)
page.content=json.dumps(content_data,cls=DjangoJSONEncoder)
page.save()
for revision in page.revisions.all():
content_data = json.loads(revision.content_json)
revision_data = content_data
migrate_block(content_data)
# StreamField data is stored in revision.content_json in a string field
revision_data['content'] = json.dumps(content_data)
revision.content_json = json.dumps(
revision_data, cls = DjangoJSONEncoder)
revision.save()
class Migration(migrations.Migration):
dependencies = [
('home', '0021_auto_20220218_1943'),
]
operations = [
migrations.RunPython(forwards, forwards),
]
Gestión de paquetes python
Para tener bien controladas las dependencias, versiones en varios entornos, te recomiento utilizar una instalación de python autocontenida e independiente. La utilidad pipenv
facilita mucho el mantenimiento de los módulos python
haciendolo completamente independiente de la instalación del sistema, asegurando que están los modulos necesarios para la aplicación y con la versión requerida.
Internacionalizacion
No tiene secretos, utilizo el módulo wagtail-localize
siguiendo la documentación. En el momento de construir este site tuve que utilizar la versión 1.1rc1 buscando la compatibilidad con la última versión de wagtail
disponible. Si fuese así en tu caso, necesitarías instalarlo de esta forma:
pipenv install \
-e git+https://github.com/wagtail/wagtail-localize.git@v1.1rc1#egg=wagtail-localize"
Por otro lado, en la integración del blog me he encontrado con un problema en un método de la clase EntryPageServe
de puput
. Como no es bueno corregirlo directamente en las fuentes, decido sobreescribir el método afectado en ejecución de esta forma. Fïjate en la última línea:
from django.http import Http404, HttpResponse
from wagtail.core import hooks
from wagtail.core.models import Site
from puput.utils import strip_prefix_and_ending_slash, import_model
from django.views.decorators.csrf import ensure_csrf_cookie
from django.utils.decorators import method_decorator
from puput.views import EntryPageServe
@method_decorator(ensure_csrf_cookie)
def get(self, request, *args, **kwargs):
site = Site.find_for_request(request)
if not site:
raise Http404
if request.resolver_match.url_name == 'entry_page_serve_slug':
splited_path = strip_prefix_and_ending_slash(request.path).split("/")
path_components = splited_path[:-4] + splited_path[-1:]
else:
path_components = [strip_prefix_and_ending_slash(request.path).split('/')[-1]]
#page, args, kwargs = site.root_page.specific.route(request, path_components) <<-----
page, args, kwargs = site.root_page.localized.specific.route(request, path_components)
for fn in hooks.get_hooks('before_serve_page'):
result = fn(page, request, args, kwargs)
if isinstance(result, HttpResponse):
return result
return page.serve(request, *args, **kwargs)
setattr(EntryPageServe,'get',get)
Galería de astrofotografía o como extender el modelo de puput
Afortunadamente puput
permite extender el modelo de páginas con mucha facilidad sin tener que reescribir mucho código ni tocar nada del original.
Por un lado me escribo mi propia clase abstracta de entrada de blog, añadiendo un nuevo campo del tipo StreamBlock
:
class ContentBlock(StreamBlock):
heading = blocks.CharBlock(form_classname="full title")
paragraph = blocks.RichTextBlock()
markdown = MarkdownBlock(icon="code")
image = ImageChooserBlock()
class MyEntryAbstract(EntryAbstract):
body = RichTextField(verbose_name=_('body'),blank=True)
content = StreamField(ContentBlock(),blank=True)
EntryAbstract.content_panels = [
MultiFieldPanel(
[
FieldPanel('title', classname="title"),
ImageChooserPanel('header_image'),
FieldPanel('excerpt', classname="full"),
FieldPanel('body', classname="full"),
StreamFieldPanel('content'),
],
heading=_("Content")
),
] + EntryAbstract.content_panels[1:]
class Meta:
abstract = True
Nos podemos olvidar, tal y como indica la documentación, el añadir las siguientes líneas en el archivo de propiedades settings/base.py
del entorno django donde correrá la web. De esta forma la clase Entry
heredará los métodos de MyEntryAbstract
:
MIGRATION_MODULES = {'puput': 'blog.puput_migrations'}
PUPUT_ENTRY_MODEL = 'blog.abstracts.MyEntryAbstract'
Por otro escribo una nueva clase hija de la EntryPage
de puput, heredando los métodos de la clase abstracta anterior, tal y como hemos explicado.
class TakenDate(Orderable):
taken_date = models.DateField(blank=True);
page = ParentalKey(
'AstrophotoEntryPage',
on_delete=models.CASCADE,
related_name='taken_dates',
)
panels = [
FieldPanel('taken_date')
]
class TimeUnitsChoicesIterable(object):
def __iter__(self):
tagchoices = [('s','Seconds'),('ms','miliSeconds'),('fps','Frames per Second')]
return tagchoices.__iter__()
class TakenFrames(models.Model):
filter = models.CharField(max_length=20, blank=False)
frames = models.PositiveSmallIntegerField(blank=False)
time = models.PositiveSmallIntegerField(blank=False)
time_unit = models.CharField(max_length=3, blank=False, default="s")
temperature = models.IntegerField(blank=True,null=True)
page = ParentalKey(
'AstrophotoEntryPage',
on_delete=models.CASCADE,
related_name='taken_frames',
)
timeunit_select = forms.Select()
timeunit_select.choices = TimeUnitsChoicesIterable()
panels = [
FieldPanel('filter'),
FieldPanel('frames'),
FieldPanel('time'),
FieldPanel('time_unit',widget=timeunit_select),
FieldPanel('temperature'),
]
class AstrophotoEntryPage(EntryPage):
camera = models.CharField(max_length=80, blank=True)
filters = models.CharField(max_length=200, blank=True)
telescope = models.CharField(max_length=80, blank=True)
mount = models.CharField(max_length=80, blank=True)
guiding = models.CharField(max_length=200, blank=True)
other = models.CharField(max_length=400, blank=True)
software = models.CharField(max_length=200, blank=True)
site = models.CharField(max_length=80, blank=True)
content_panels = EntryPage.content_panels[:1] + [
MultiFieldPanel([
InlinePanel('taken_dates', min_num=1, max_num=6, label="Dates"),
InlinePanel('taken_frames', label="Frames"),
FieldPanel('site'),
], heading="Adquisition Details"),
MultiFieldPanel([
FieldPanel('camera'),
FieldPanel('filters'),
FieldPanel('telescope'),
FieldPanel('mount'),
FieldPanel('guiding'),
FieldPanel('other'),
FieldPanel('software'),
], heading="Equipment"),
] + EntryPage.content_panels[1:]
BlogPage.subpage_types.append(AstrophotoEntryPage)
Esta última línea de código es fundamental, si quiero añadir este nuevo tipo de post, además del original.
Carousel de entradas del blog con puput
Una de las caractarísticas que buscaba era la de poder mostrar carouseles de imágenes y de categorias del blog. Para ello construiremos varios template tag blog/templatetags/blog_tags.py
:
from importlib import import_module
from django.template import Library
from django.urls import resolve
from django.template.defaultfilters import urlencode
from django.template.loader import render_to_string
from django_social_share.templatetags.social_share import _build_url
from el_pagination.templatetags.el_pagination_tags import show_pages, paginate
from puput.utils import import_model
from puput.models import Category, Tag
from wagtail.core.models import Site, Locale
import uuid
register = Library()
@register.inclusion_tag('puput/tags/my_entries_list.html', takes_context=True)
def my_recent_entries(context, limit=None, page=None, request=None):
blog_page_m = import_module('puput.models').BlogPage
locale_id=context['page'].locale.id
blog_page = blog_page_m.objects.filter(locale=locale_id).first()
context['blog_page'] = blog_page
context['carousel_id'] = str(uuid.uuid4())
entries = blog_page.get_entries().exclude(header_image__isnull=True).order_by('-date')
if limit:
entries = entries[:limit]
context['entries'] = entries
return context
@register.inclusion_tag('puput/tags/my_entries_list.html', takes_context=True)
def my_entries_by_category(context, category, limit=None):
blog_page_m = import_module('puput.models').BlogPage
locale_id=context['page'].locale.id
blog_page = blog_page_m.objects.filter(locale=locale_id).first()
context['blog_page'] = blog_page
context['carousel_id'] = str(uuid.uuid4())
entries = blog_page.get_entries().filter(entry_categories__category__name=category)
entries = entries.exclude(header_image__isnull=True).order_by('-date')
if limit:
entries = entries[:limit]
context['entries'] = entries
return context
@register.inclusion_tag('puput/tags/my_entries_list.html', takes_context=True)
def my_entries_by_tag(context, label, limit=None):
blog_page_m = import_module('puput.models').BlogPage
locale_id=context['page'].locale.id
blog_page = blog_page_m.objects.filter(locale=locale_id).first()
context['blog_page'] = blog_page
context['carousel_id'] = str(uuid.uuid4())
entries = blog_page.get_entries().filter(tags__name=label)
entries = entries.exclude(header_image__isnull=True).order_by('-date')
if limit:
entries = entries[:limit]
context['entries'] = entries
return context
con su correspondiente template puput/tags/my_entries_list.html
para la presentación:
{% load i18n wagtailimages_tags puput_tags %}
<div id="mycarousel_{{ carousel_id }}" class="carousel slide mycarousel-{{self.carousel_type}} {%if self.label%}blog-carousel-{{self.label}}{%endif%}" data-ride="carousel">
<div class="carousel-inner">
{% for entry in entries %}
{% image entry.header_image fill-800x450 as carousel_img %}
<div class="carousel-item {% if forloop.first %}active{% endif %}">
<a href="{% entry_url entry blog_page %}">
<img src="{{ carousel_img.url }}" class="d-block w-100" alt="{{ carousel_img.alt }}">
</a>
<div class="carousel-caption">
<h3>{{ entry.title }}</h3>
<p>{{ entry.date|date:"SHORT_DATE_FORMAT" }}</p>
</div>
</div>
{% endfor %}
</div>
<a class="carousel-control-prev" href="#mycarousel_{{ carousel_id }}" role="button" data-slide="prev">
<span class="carousel-control-prev-icon"></span>
</a>
<a class="carousel-control-next" href="#mycarousel_{{ carousel_id }}" role="button" data-slide="next">
<span class="carousel-control-next-icon"></span>
</a>
</div>
De este modo, podemos incluir el carousel en un template normal, quedando de esta forma:
<section>
<h3>{% trans "Blog Last Entries" %}</h3>
{% my_recent_entries 10 %}
<h3>Category Entries</h3>
{% my_entries_by_category "Astronomy" 5 %}
<h3>Tags Entries</h3>
{% my_entries_by_tag "Planetary" 5 %}
</section>
Para terminar, así quedaría un StructBlock
con carousel de últimas entradas del blog,
class CarouselBlogChoiceBlock(blocks.ChoiceBlock):
choices = (
('all', 'Recent entries'),
('tagged', 'Recent tagged entries'),
('categories', 'Recent categories entries'),
)
class AlignChoiceBlock(blocks.ChoiceBlock):
choices = (
('left', 'Float Left'),
('right', 'Float Right'),
('center', 'Center'),
)
class CarouselBlogBlock(StructBlock):
carousel_type = AlignChoiceBlock(label='Carousel Type', default='center')
carousel_blog_type = CarouselBlogChoiceBlock(label='Entry Filter', default='all')
label = CharBlock(max_length=80)
limit = IntegerBlock(min_value=5, max_value=20, default=5)
class Meta:
icon = "cog"
template = "streamfieldblocks/carousel_blog_block.html"
quedando el template carousel_blog_block.html
de esta forma,
{% load wagtailimages_tags wagtailcore_tags blog_tags %}
{% if self.carousel_blog_type == 'all' %}
{% my_recent_entries limit page request%}
{% elif self.carousel_blog_type == 'categories' %}
{% my_entries_by_category self.label self.limit %}
{% elif self.carousel_blog_type == 'tagged' %}
{% my_entries_by_tag self.label self.limit %}
{% endif %}
Soporte LaTeX para fórmulas matemáticas y coloreo de código
El soporte para $\LaTeX$ lo hicimos usando javascript gracias a la biblioteca MathJax
, algo de código de configuración en el template de base
<script type="text/x-mathjax-config">
MathJax.Hub.Config({
extensions: ["tex2jax.js"],
jax: ["input/TeX", "output/HTML-CSS"],
tex2jax: {
inlineMath: [['$','$']],
displayMath: [['$$','$$']] ,
processEscapes: true
},
"HTML-CSS": { availableFonts: ["TeX"] }
});
</script>
...
<script type="text/javascript"
src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/MathJax.js?config=TeX-AMS-MML-AM_CHTML">
</script>
y un poco de código de integración con markdown
donde además añadimos soporte para codehilite
y así dar un poco de color al código, mysite/md_converter/utils.py
:
import markdown
def render_markdown(value):
html = markdown.markdown(
value,
extensions=[
'extra',
'codehilite',
'mysite.md_converter.mdx.mdx_mathjax',
],
extension_configs={
'codehilite': [
('guess_lang', False),
]
},
output_format='html5'
)
return html
Este sería el código de la extensión, tomando como base python-markdown-mathjax y teniendo en cuenta este hilo de stackoverflow, mysite/md_converter/mdx/mdx_mathjax.py
:
import markdown
import html
class MathJaxPattern(markdown.inlinepatterns.Pattern):
def __init__(self, md):
markdown.inlinepatterns.Pattern.__init__(
self, r'(?<!\\)(\$\$?)(.+?)\2', md)
def handleMatch(self, m):
text = html.escape(m.group(2) + m.group(3) + m.group(2))
return self.markdown.htmlStash.store(text)
class MathJaxExtension(markdown.Extension):
def __init__(self, **kwargs):
super(MathJaxExtension, self).__init__(**kwargs)
def extendMarkdown(self, md, md_globals):
md.inlinePatterns.add('mathjax', MathJaxPattern(md), '<escape')
def makeExtension(configs={}):
return MathJaxExtension(**configs)
También habría que añadir el siguiente templatetag
from django import template
from mysite.md_converter.utils import render_markdown
register = template.Library()
@register.filter(name='markdown')
def markdown(value):
return render_markdown(value)
para usarlo como filtro en los templates principales de html:
...
</div>
{% elif iner_block.block_type == 'markdown' %}
<div class="block-{{ iner_block.block_type }}">
{{ iner_block.value|markdown|safe }}
</div>
{% elif iner_block.block_type == 'carousel_blog' %}
...
y una vez configurado todo, a modo de ejemplo, este sería el resultado:
\begin{equation} \begin{aligned} \frac{\partial \vec {\mathcal D}}{\partial t} & = \nabla \times \vec{\mathcal H} \\ \frac{\partial \vec {\mathcal B}}{\partial t} & = -\nabla \times \vec{\mathcal E} \\ \nabla \cdot \vec {\mathcal B} & = 0 \\ \nabla \cdot \vec {\mathcal D} & = 0 \end{aligned} \end{equation}
o esta forma, que me gusta más
\begin{equation} \begin{aligned} \nabla \times \vec{\mathcal B} & = \mu_0 \left( \vec {\mathcal J} + \varepsilon_0 \frac{\partial \vec {\mathcal E}}{\partial t} \right) \\ \nabla \times \vec{\mathcal E} & = - \frac{\partial \vec {\mathcal B}}{\partial t} \\ \nabla \cdot \vec {\mathcal B} & = 0 \\ \nabla \cdot \vec {\mathcal E} & = \frac{\rho}{\varepsilon_0} \end{aligned} \end{equation}
si nos fijamos en el código html generado vemos que una estructura xml MathML, de forma que navegadores que lo soportan puedan renderizar las fórmulas directamente.