Day 6 Dev Notes

This morning, I wanted to implement a simple, category-based, menu in the navbar. As with most things, this was simple to implement, but I then spent some time optimising it, which lead me down an interesting pathway. Here's what I accomplished...

  • Created a template tag to retrieve the category queryset.
@register.simple_tag
def get_categories() -> models.QuerySet[Category] | None:
    """Return all categories."""
    return Category.get_categories()
  • Used that template tag in the navbar template snippet to loop through the categories and create a basic menu.
      {% get_categories as categories %}

      {% for category in categories %}
        <li class="nav-item">
          <a class="nav-link" href="{% url 'djpress:category_posts' category.slug %}">{{ category.name }}</a>
        </li>
      {% endfor %}
  • I didn't like the idea of introducing a new SQL query on each page load, so I implemented a basic local memory cache.
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
    },
}
  • And then modified the Category model to get or set the cache when querying for categories:
CATEGORY_CACHE_KEY = "categories"

...

    @classmethod
    def _get_categories(cls: type["Category"]) -> models.QuerySet:
        """Return all categories."""
        return cls.objects.all()

    @classmethod
    def get_cached_queryset(cls: type["Category"]) -> models.QuerySet:
        """Return the cached categories queryset."""
        queryset = cache.get(CATEGORY_CACHE_KEY)
        if queryset is None:
            queryset = cls._get_categories()
            cache.set(CATEGORY_CACHE_KEY, queryset, timeout=None)
        return queryset
  • This also required a new signals.py file to hold the signals that are required to invalidate the cache when required.
@receiver(post_save, sender=Category)
@receiver(post_delete, sender=Category)
def invalidate_category_cache(**kwargs) -> None:  # noqa: ARG001, ANN003
    """Invalidate the category cache."""
    cache.delete(CATEGORY_CACHE_KEY)
  • Then spent some time troubleshooting why the signals weren't working before realising they need to be registered in the AppConfig.
def ready(self: "DjpressConfig") -> None:
        """Import signals."""
        import djpress.signals  # noqa: F401
  • That achieved what I wanted, but the last change I made was to auto-generate the Category slug if it's not provided, using a similar method to what I was already doing for the Post model.
def save(self: "Category", *args, **kwargs) -> None:  # noqa: ANN002, ANN003
        """Override the save method to auto-generate the slug."""
        if not self.slug:
            self.slug = slugify(self.name)
            if not self.slug or self.slug.strip("-") == "":
                msg = "Invalid name. Unable to generate a valid slug."
                raise ValueError(msg)
        super().save(*args, **kwargs)

Now that I have this basic caching framework in place, the next step will be to introducing more caching, especially for the Content model.

Github link to changed files

Update

Did some more work today, which started with the aim of doing some caching of posts. This spiralled out of control a bit and brought about a lot of refactoring. But the end result was that I'm now caching the published posts queryset which means that once cached, I can view the home page and click through to a single post without touching the database.

Github Link