Un sitio personal con Wagtail ...

Captura de pantalla de 2022-03-25 07-27-38

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.

joslumar archieological web page

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.

joslumar 20 century web

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.

lynx-oldweb

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 aplicaciones django 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:

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.

Aviso de cookies

Estoy de acuerdo Este sitio web guarda pequeños fragmentos de información (cookies) en su dispositivo solo con una finalidad funcional. Son cookies técnicas y de personalización. No guardamos informacion confidencial ni trazamos actividad de navegación ni las usamos para publicidad. Las cookies usadas por este sitio quedan excluidas del ámbito de aplicación del artículo 22.2 de la LSSI. No obstante, usted puede desactivar el uso de cookies modificando la configuración de su navegador.