13 Sep 2024
Django community aggregator: Community blog posts
django-content-editor now supports nested sections
django-content-editor now supports nested sections
django-content-editor (and it's ancestor FeinCMS) has been the Django admin extension for editing content consisting of reusable blocks since 2009. In the last years we have more and more often started automatically grouping related items, e.g. for rendering a sequence of images as a gallery. But, sometimes it's nice to give editors more control. This has been possible by using blocks which open a subsection and blocks which close a subsection for a long time, but it hasn't been friendly to content managers, especially when using nested sections.
The content editor now has first-class support for such nested sections. Here's a screenshot showing the nesting:
Finally it's possible to visually group blocks into sections, collapse those sections as once and drag and drop whole sections into their place instead of having to select the involved blocks individually.
The best part about it is that the content editor still supports all Django admin widgets, as long as those widgets have support for the Django administration interface's inline form events! Moving DOM nodes around breaks attached JavaScript behaviors, but we do not actually move DOM nodes around after the initialization - instead, we use Flexbox ordering to visually reorder blocks. It's a bit more work than using a ready-made sortable plugin, but - as mentioned - the prize is that we don't break any other Django admin extensions.
Simple patterns
I previously already reacted to a blog post by Lincoln Loop here in my post My reaction to the block-driven CMS blog post.
The latest blog post, Solving the Messy Middle: a Simple Block Pattern for Wagtail CMS was interesting as well. It dives into the configuration of a Wagtail stream field which allows composing content out of reusable blocks of content (sounds familiar!). The result is saved in a JSON blob in the database with all the advantages and disadvantages that entails.
Now, django-content-editor is a worthy competitor when you do not want to add another interface to your website besides the user-facing frontend and the Django administration interface.
The example from the Lincoln Loop blog post can be replicated quite closely with django-content-editor by using sections. I'm using the django-json-schema-editor package for the section plugin since it easily allows adding more fields if some section type needs it.
Here's an example model definition:
# Models
from content_editor.models import Region, create_plugin_base
from django_json_schema_editor.plugins import JSONPluginBase
from feincms3 import plugins
class Page(models.Model):
# You have to define regions; each region gets a tab in the admin interface
regions = [Region(key="content", title="Content")]
# Additional fields for the page...
PagePlugin = create_plugin_base(Page)
class RichText(plugins.richtext.RichText, PagePlugin):
pass
class Image(plugins.image.Image, PagePlugin):
pass
class Section(JSONPluginBase, PagePlugin):
pass
AccordionSection = Section.proxy(
"accordion",
schema={"type": "object", {"properties": {"title": {"type": "string"}}}},
)
CloseSection = Section.proxy(
"close",
schema={"type": "object", {"properties": {}}},
)
Here's the corresponding admin definition:
# Admin
from content_editor.admin import ContentEditor
from django_json_schema_editor.plugins import JSONPluginInline
from feincms3 import plugins
@admin.register(models.Page)
class PageAdmin(ContentEditor):
inlines = [
plugins.richtext.RichTextInline.create(models.RichText),
plugins.image.ImageInline.create(models.Image),
JSONPluginInline.create(models.AccordionSection, sections=1),
JSONPluginInline.create(models.CloseSection, sections=-1),
]
The somewhat cryptic sections=
argument says how many levels of sections the individual blocks open or close.
To render the content including accordions I'd probably use a feincms3 renderer. At the time of writing the renderer definition for sections is a bit tricky.
from feincms3.renderer import RegionRenderer, render_in_context, template_renderer
class PageRenderer(RegionRenderer):
def handle(self, plugins, context):
plugins = deque(plugins)
yield from self._handle(plugins, context)
def _handle(self, plugins, context, *, in_section=False):
while plugins:
if isinstance(plugins[0], models.Section):
section = plugins.popleft()
if section.type == "close":
if in_section:
return
# Ignore close section plugins when not inside section
continue
if section.type == "accordion":
yield render_in_context("accordion.html", {
"title": accordion.data["title"],
"content": self._handle(plugins, context, in_section=True),
})
else:
yield self.render_plugin(plugin, context)
renderer = PageRenderer()
renderer.register(models.RichText, template_renderer("plugins/richtext.html"))
renderer.register(models.Image, template_renderer("plugins/image.html"))
renderer.register(models.Section, "")
Closing thoughts
Sometimes, I think to myself, I'll "just" write a "simple" blog post. I get what I deserve when using those forbidden words. This blog post is neither short or simple. That being said, the rendering code is a bit tricky, the rest is quite straightforward. The amount of code in django-content-editor and feincms3 is reasonable as well. Even though it may look like a lot you'll still be running less code in production than when using comparable solutions built using Django.
13 Sep 2024 5:00pm GMT
Django News - Python 3.13.0RC2 - Sep 13th 2024
News
Python 3.13.0RC2 and security updates for 3.8 through 3.12
Python 3.13.0RC2 and security updates for Python 3.12.6, 3.11.10, 3.10.15, 3.9.20, and 3.8.20 are now available!
DjangoCon US 2024 last call!
DjangoCon US starts September 22nd. It's the last call to buy an in-person or online ticket to attend this year!
Python in Visual Studio Code - September 2024 Release
The Python extension now supports Django unit tests.
Updates to Django
Today 'Updates to Django' is presented by Raffaella Suardini from Djangonaut Space!
Last week we had 12 pull requests merged into Django by 10 different contributors - including 4 first-time contributors! Congratulations to SirenityK, Mariatta, Wassef Ben Ahmed and github-user-en for having their first commits merged into Django - welcome on board!
Last chance to apply for Djangonaut Space 🚀
The application will close on September 14, for more information check this article that explains the selection process. Apply here
Django Newsletter
Sponsored Link 1
HackSoft - Your Django Development Partner Beyond Code
Elevate your Django projects with HackSoft! Try our expert consulting services and kickstart your project.
Articles
Django from first principles, part 18
The final post in a series on building and refactoring a Django blog site from scratch.
Django: rotate your secret key, fast or slow
Adam Johnson covers the two main ways to rotate secret keys, including a Django 4.1 feature that allows rotating to a new key whilst accepting data signed with the old one.
django-filter: filtering a foreign key model property
How to filter a foreign key model property with django-filter.
Django: a pattern for settings-configured API clients
How to get around the problem that an API client is instantiated as a module-level variable based on some settings.
UV with Django
Using UV to manage dependencies of your Django application.
Signatures are like backups · Alex Gaynor
"Backups don't matter, only restores matter."
Tutorials
Django-allauth: Site Matching Query Does Not Exist
How to fix a common configuration mistake in django-allauth.
Videos
Djangonaut Space Overview and Ask Me Anything (AMA)
This is an explanation of the Djangonaut Space program sessions, with a Q&A at the end. It has specific details on Session 3 of 2024, but the information is relevant for future sessions.
Session 3 applications are closed on September 14th, so apply if interested!
DjangoCon EU 2013 - Class-Based Views: Untangling the mess
This talk is from 2013, but it is still relevant to anyone dealing with function-based and (generic) class-based views. Russell Keith-Magee goes into the history of why GCBVs were added.
Sponsored Link 2
Try Scout APM for free!
Sick of performance issues? Enter Scout's APM tool for Python apps. Easily pinpoint and fix slowdowns with intelligent tracing logic. Optimize performance hassle-free, delighting your users.
Podcasts
Django Chat #165: Fall 2024 Podcast Relaunch
This mini-episode starts off the fall season and focuses on what's new in Django, upcoming DjangoCon US talks, thoughts on the User model, Carlton's new Stack Report newsletter, mentoring mentors, and more.
Django News Jobs
Back-end developers at ISM Fantasy Games 🆕
Python Engineer - API and SaaS Application Development at Aidentified, LLC
Software Developer at Habitat Energy
Senior Fullstack Python Engineer at Safety Cybersecurity
Django Newsletter
Projects
kennethlove/django-migrator
The Migrator project provides custom Django management commands to manage database migrations. It includes commands to revert and redo migrations for a specified app or the entire project.
carltongibson/django-unique-user-email
Enable login-by-email with the default User model for your Django project by making auth.User.email unique.
This RSS feed is published on https://django-news.com/. You can also subscribe via email.
13 Sep 2024 3:00pm GMT
Cloud Migration Beginning - Building SaaS #202
In this episode, we started down the path of migrating School Desk off of Heroku and onto Digital Ocean. Most of the effort was on tool changes and beginning to make a Dockerfile for deploying the app to the new setup.
13 Sep 2024 5:00am GMT
Rescuing Django Projects with Smoke Tests: Quick Wins for Long-Term Success
We often inherit existing Django projects at Lincoln Loop either to provide ongoing maintenance or build new features (or both). Usually these projects are in some state of neglect and disrepair when they come to us. If they were a house, they might be on the verge of being condemned and require some urgent repairs to prevent a catastrophe.
To make matters worse, the tests are usually non-existent or broken to start with. So how do you make changes and have some level of confidence that you aren't breaking other things in the process?
Writing a complete test suite from scratch would be a large (expensive) undertaking and one that would not deliver much in terms of immediate value. We use smoke tests.
Smoke tests give you the maximum coverage with the minimum effort. Pick out the key views or API endpoints and test that they return a 200 status code. Got something that requires a login? Verify it returns a 302, 401, or similar.
Here are a few examples of smoke tests in Django (also using the wonderful model-bakery library):
Are these tests perfect? Definitely not. Lots can go wrong that these tests won't catch.
Are they better than nothing? Absolutely! They will exercise large swaths of code and will catch show-stopper bugs that otherwise might go undetected.
With smoke tests we can move a little more confidently in a foreign codebase, allowing us to fix the most egregious problems
What's Next
We want to work on improving tests and coverage over time while still delivering immediate business value to the client throughout the process.
We need to know if our fledgling tests are passing (and not just on the developer's laptop), so if there's no CI, we will get something basic going there. Usually in GitHub Actions or AppPack.
Then we start working with a "leave it better than you found it" philosophy. Need to fix a bug in an untested portion of the code? Deliver it with a test. Writing a new feature? Deliver it with a test.
Over time, both the quality and coverage of the test suite will improve and you'll be able to rescue the project from condemnation.
13 Sep 2024 1:07am GMT
11 Sep 2024
Django community aggregator: Community blog posts
Fall 2024 Podcast Relaunch
- The Stack Report
- LearnDjango.com
- Podcast Sponsorship Info
- django-unique-user-email, neapolitan, and django-template-partials
- awesome-django and djangox
- django-allauth Tutorial
- Carlton on Fosstodon and Will on Fosstodon
11 Sep 2024 10:00pm GMT
Weeknotes (2024 week 37)
Weeknotes (2024 week 37)
django-debug-toolbar alpha with async support!
I have helped mentoring Aman Pandey who has worked all summer to add async support to django-debug-toolbar. Tim has released an alpha which contains all of the work up to a few days ago. Test it! Let's find the breakages before the final release.
Dropping Python 3.9 from my projects
I have read Carlton's post about the only green Python release and have started dropping Python 3.9 support from many of the packages I maintain. This is such a good point:
[…] I'm also thinking about it in terms of reducing the number of Python versions we support in CI. It feels like a lot of trees to support 5 full versions of Python for their entire life. 🌳
Releases
- django-debug-toolbar 5.0.0a0: See above.
- form-designer 0.26.2: The values of choice fields are now returned as-is when sending mails or exporting form submissions instead of only returning the slugified version.
- django-authlib 0.17.1: The role-based permissions backend had a bug where it wouldn't return all available permissions in all circumstances, leading to empty navigation sidebars in the Django administration. This has been fixed.
- feincms3 5.2.3: Bugfix release, the page moving interface is no longer hidden by an expanded navigation sidebar. I almost always turn off the sidebar in my projects so I haven't noticed this.
- django-prose-editor 0.8.1: Contains the most recent ProseMirror updates and bugfixes.
- django-content-editor 7.0.10: Now supports sections. Separate blog post coming up!
11 Sep 2024 5:00pm GMT
Django-allauth: Site Matching Query Does Not Exist
Have you tried installing [django-allauth](https://docs.allauth.org/en/latest/) and encountered this particular error? I did, too, recently, and I've been successfully using `django-allauth` for years, so …
11 Sep 2024 3:52pm GMT
08 Sep 2024
Django community aggregator: Community blog posts
Django: hoist repeated decorator definitions
Django provides us with a rich set of view decorators. In this post, we'll look at a technique for hoisting repeated use of these decorators to reduce repetition.
Repeated @cache_control
calls
Here are two public views with the same @cache_control
decorator:
from django.views.decorators.cache import cache_control
@cache_control(max_age=60 * 60, public=True)
def about(request): ...
@cache_control(max_age=60 * 60, public=True)
def contact_us(request): ...
To avoid this repetition, we can call cache_control
once at the top of the module and use that result as the decorator:
from django.views.decorators.cache import cache_control
cache_public = cache_control(max_age=60 * 60, public=True)
@cache_public
def about(request): ...
@cache_public
def team(request): ...
This works because cache_control
is technically not a decorator but a function that returns a decorator. So we can separate the call of cache_control
from the decorating.
Aside from reducing redundant repetition, this technique also saves a tiny bit of time and memory when importing the module, because cache_control
is only called once.
Repeated @require_http_methods
calls
Here's another example, instead using @require_http_methods
:
from django.views.decorators.http import require_http_methods
require_GET_POST = require_http_methods(("GET", "POST"))
@require_GET_POST
def contact_us(request): ...
@require_GET_POST
def store_feedback(request): ...
(Actually, it would be neat if Django provided require_GET_POST
out of the box…)
Hoisting @method_decorator
calls for class-based views
This technique is particularly beneficial for class-based views, where view decorators mostly need extra wrapping with method_decorator
:
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from django.views.generic import TemplateView
cache_public = method_decorator(cache_control(max_age=60 * 60, public=True))
@cache_public
class AboutView(TemplateView): ...
@cache_public
class TeamView(TemplateView): ...
I also like to use this technique with decorators that don't take arguments, such as the new @login_not_required
from Django 5.1:
from django.contrib.auth.decorators import login_not_required
from django.utils.decorators import method_decorator
from django.views.generic import TemplateView
login_not_required_m = method_decorator(login_not_required, name="dispatch")
@login_not_required_m
class AboutView(TemplateView): ...
@login_not_required_m
class TeamView(TemplateView): ...
I like adding an "m" suffix to the variable name to indicate that it's a method decorator version of the original.
Test decorators
This deduplication technique can also dramatically improve test readability, where many tests often need the same decorator applied. For example, third-party apps may mark version-restricted tests with unittest's @skipIf
or pytest's @pytest.mark.skipif
:
from unittest import skipIf
import django
django_5_1_plus = skipIf(django.VERSION < (5, 1), "Django 5.1+ required")
class AcmeAuthMiddlewareTests(TestCase):
...
@django_5_1_plus
def test_view_login_not_required(self): ...
@django_5_1_plus
def test_view_login_required(self): ...
08 Sep 2024 4:00am GMT
06 Sep 2024
Django community aggregator: Community blog posts
django-filter: filtering a foreign key model property
django-filter is a fantastic library that makes easy to add complex filters in Django. While it requires very little code to get up and running, it can be a bit tricky to figure out how to do more non-standard things, like for example filtering against a foreign key model property.
In this brief post we'll see with an example how I've achieved this.
The models
Consider two simple Django models, Book
and Author
:
from django.db import models
class Book(models.Model):
title = models.CharField(max_length=100)
author = models.ForeignKey("Author", on_delete=models.CASCADE)
def __str__(self):
return self.title
class Author(models.Model):
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
@property
def full_name(self):
return f"{self.first_name} {self.last_name}"
def __str__(self):
return self.full_name
Book
has a ForeignKey
to Author
, and Author
has a full_name
property that returns the full name of the author.
My use case is to be able to filter the Book
list by the Author
full_name
property.
Django filter would make easy to filter against a model field, that is, things that are actual database columns, like author__first_name
or author__last_name
, but filtering against a model property like author__full_name
is not so straightforward.
In other words, we would like to hit our page at /books/?author_full_name=clarke
and get all the books written by Arthur C. Clarke.
The filter
To clarify things better, here's how the filter would look like if we were filtering against a model field:
from django_filters import FilterSet
from .models import Book
class BookFilterSet(FilterSet):
class Meta:
model = Book
fields = ["author__last_name"]
This will work fine for hitting /books/?author_first_name=clarke
. What happens instead if we try to filter against a model property? Like this:
from django_filters import FilterSet
from .models import Book
class BookFilterSet(FilterSet):
class Meta:
model = Book
fields = ["author__full_name"]
Django filter will complain with this error:
TypeError: 'Meta.fields' must not contain non-model field names: author__full_name
Let's see how to fix this!
Fixing the filter, wiring up the view and the queryset
To make Django filter work with a model property, first off we need to define a custom filter for that property:
from django_filters import FilterSet, CharFilter
from .models import Book
class BookFilterSet(FilterSet):
author_full_name = CharFilter(lookup_expr="icontains", label="Author full name")
class Meta:
model = Book
fields = ["other_fields_here"]
Here, the BookFilterSet
is instructed to use a CharFilter
for the author_full_name
property. In this example, we also use the icontains
lookup expression.
Next up, we wire up the Django view alongside with the get_queryset
method. The "trick" to make Django filter work with a model property is to use an annotation to add a new field to the queryset, that is, the author_full_name
field:
from django.db.models import F
from django.db.models import Value as V
from django.db.models.functions import Concat
from django_filters.views import FilterView
from .models import Book
from .filters import BookFilterSet
class BookList(FilterView):
model = Book
filterset_class = BookFilterSet
template_name = "books/book_list.html"
def get_queryset(self):
return (
super()
.get_queryset()
.annotate(
author_full_name=Concat(
F("author__first_name"), V(" "), F("author__last_name")
)
)
)
Here's a breakdown of the queryset:
super().get_queryset()
gets the base queryset.annotate()
adds a new field to the querysetauthor_full_name
, by concatenatingauthor__first_name
andauthor__last_name
fields.
With this change in place, Django filter is now able to filter against the author_full_name
property. This way, we can hit /books/?author_full_name=clarke
in Django, and have the filter work properly.
Here's the query again:
def get_queryset(self):
return (
super()
.get_queryset()
.annotate(
author_full_name=Concat(
F("author__first_name"), V(" "), F("author__last_name")
)
)
)
Notice the use of F()
and V()
expressions. F()
is used to reference a field in the database. The database function Concat()
instead is used to concatenate the fields. If you want, you can read more about expressions in Query Expressions, and functions in Database Functions.
Conclusion
django-filter is a fantastic, easy to use, well-documented library for filtering in Django. While it works almost out of the box with model fields, it requires a bit of extra work to handle Django model properties.
In this post, we saw how achieve that by using an annotation to add a new field to the queryset, and then using that field in the filter.
Another alternative, if you don't mind using a third party library, is to use django-queryable-properties.
Thanks for reading!
06 Sep 2024 4:00pm GMT
Django News - Django security releases issued: 5.1.1, 5.0.9, and 4.2.16 - Sep 6th 2024
News
Django security releases issued: 5.1.1, 5.0.9, and 4.2.16
Two security fixes were just released. As always, the best security measure you can take is to always be on the latest version of Django.
Announcing Djangonaut Space Session 3 Applications Open!
Session 3 applications are now open until September 14th.
Sponsored Link 1
Try Scout APM for free!
Sick of performance issues? Enter Scout's APM tool for Python apps. Easily pinpoint and fix slowdowns with intelligent tracing logic. Optimize performance hassle-free, delighting your users.
Articles
Evolving Django's auth.User
Former Django Fellow Carlton Gibson has a lengthy and well-reasoned take on the auth.User
model, pointing out its current limitations and forcefully arguing for some changes in future releases.
Extending the Django OAuth Toolkit Application Model Mid-project
Extending the Django OAuth Toolkit's application model mid-project can cause significant challenges, but Mariatta walks us through it.
Redis connections growing unbounded
gunicorn+gevent and Celery can be tricky. There is a setting that is often missed that keeps your Redis connections from increasing until they reach the maximum.
Implementing Search with Django CMS 4
Learn how to implement custom search functionality on a Django CMS 4 site using Django Haystack for dynamic content indexing.
Taming the beast that is the Django ORM - An introduction
A guide to navigating Django ORM's power and pitfalls, with tips on querying, performance, and avoiding common gotchas.
Django: avoid "useless use of .all()"
Adam Johnson reminds us that when you write a queryset with filter()
you do not need to use all()
.
Events
Django Day Copenhagen 2024 Talk Schedule
The 9 talks for this year's conference are now live. It's a very full day.
DjangoCon US 2024
DCUS is coming soon, September 22-27, again in Durham, NC. Tickets for tutorials, talks, and sprints are still available.
Tutorials
Django Redirects App Tutorial
A step-by-step configuration and implementation guide to Django's built-in redirects app, useful for using the admin to manage all your URL redirects.
Build a One-Product Shop With the Python Django Framework and Htmx
We'll use Django, htmx, and Stripe to create a one-product e-commerce website.
Django News Jobs
Python Engineer - API and SaaS Application Development at Aidentified, LLC
Software Developer at Habitat Energy
Senior Software Engineer at Multi Media LLC
Senior Fullstack Python Engineer at Safety Cybersecurity
Django Newsletter
Projects
davidism/gha-update
Update GitHub Actions version pins in GitHub workflow files. Even with grouped, monthly updates, Dependabot PRs can still be noisy, especially for smaller or stable projects. This tool allows updating pins locally, when the maintainer wants to.
adamspd/django-appointment
A Django app for managing appointment scheduling with ease and flexibility.
This RSS feed is published on https://django-news.com/. You can also subscribe via email.
06 Sep 2024 3:00pm GMT
Kamal On A Droplet - Building SaaS #201
In this episode, we continued working with the Kamal deploy tool. Last time, we build a demo image. With this session, we created a Digital Ocean Droplet (i.e., VM) and deployed to that. Along the journey, we learned a lot about how Kamal operates.
06 Sep 2024 5:00am GMT
05 Sep 2024
Django community aggregator: Community blog posts
Django: a pattern for settings-configured API clients
Here's an example of a common pattern in Django projects:
from acme.api import APIClient
from django.conf import settings
acme_client = APIClient(api_key=settings.ACME_API_KEY)
def order_anvil() -> None:
acme_client.anvils.order(...)
An API client is instantiated as a module-level variable based on some settings. This approach has some drawbacks:
-
The client doesn't get reinstantiated when settings change. This typically occurs during tests, where
@override_settings
would be useful:from django.test import TestCase, override_settings @override_settings(ACME_API_KEY="example-key") class ACMETests(TestCase): ...
Tests instead need workarounds, like patching with
unittest.mock.patch
. -
Some API clients are expensive to instantiate, even issuing HTTP requests. This cost is paid at import time, thanks to module-level instantiation. This adds overhead to code paths that don't use the client, such as unrelated management commands.
Here's an alternative pattern that avoids these problems:
from functools import cache
from acme.api import APIClient
from django.conf import settings
@cache
def get_acme_client() -> APIClient:
return APIClient(api_key=settings.ACME_API_KEY)
@receiver(setting_changed)
def reset_acme_client(*, setting, **kwargs):
if setting == "ACME_API_KEY":
get_acme_client.cache_clear()
def order_anvil() -> None:
get_acme_client().anvils.order(...)
Notes:
- The client is now instantiated on first use, within
get_acme_client()
. This function is decorated withfunctools.cache
, so the client is cached after the first call. - A new signal receiver function,
reset_acme_client()
, resets the cache when the API key setting changes. This is registered to receive thesetting_changed
signal, fired by Django's setting-changing tools for tests, including@override_settings
. order_anvil()
now callsget_acme_client()
to fetch the cached client.
This pattern requires a bit more code, but it is the easiest way to avoid the previous problems. It's used for several settings-controlled objects inside Django. For example, the loading of password hashers:
@functools.lru_cache
def get_hashers(): ...
@functools.lru_cache
def get_hashers_by_algorithm(): ...
@receiver(setting_changed)
def reset_hashers(*, setting, **kwargs):
if setting == "PASSWORD_HASHERS":
get_hashers.cache_clear()
get_hashers_by_algorithm.cache_clear()
05 Sep 2024 4:00am GMT
04 Sep 2024
Django community aggregator: Community blog posts
Django: build a Microsoft Teams bot
Recently, I built a Microsoft Teams bot for a client, inside their Django project. It wasn't fun or easy, but the experience did increase my resiliency as a developer. I also went into this forewarned by my wife, a product manager also known as "the integration queen", who has experienced the difficulties of the Teams API first-hand.
I'm writing this post to leave some breadcrumbs for future adventurers braving this path.
Issues that I encountered
At the core, a Teams bot is a straightforward affair. Microsoft Teams sends HTTP requests to your webhook, to which your code should respond appropriately. Your bot can also send extra requests outside this cycle, to send messages or perform other actions.
Unfortunately, there are a lot of complications to getting this process working. Here are some of the issues that I encountered:
-
The documentation is a rat's nest of competing terms, deprecations, and broken links. This seems to have been driven by rebranding the bot "product" and sub-products as AI and pitching for LLM-driven chatbots.
For example, the Python package is referred to all of: "Bot Framework", "Bot Framework SDK", and "Bot Builder SDK". And the Azure service for configuring a bot is called both "Azure AI Bot Service" and "Azure Bot Service".
-
The Bot Framework Python package has been needlessly split into sub-packages. They're all released together, and presumably, their versions need keeping in sync. Installing the base package (botbuilder-core) pulls in three other inconsistently named ones (botbuilder-schema, botframework-connector, botframework-streaming), and there are eight others you may want to add.
-
The framework's reference documentation contains zero information, just automatically generated function stubs.
-
Inside the framework code, many docstrings have examples written in C#.
-
Sample apps are split across two repositories: Microsoft/BotBuilder-Samples and OfficeDev/Microsoft-Teams-Samples.
-
All the sample apps are limited to aiohttp. I have nothing against this, but it's not very helpful for integrating with existing projects. It seems the only use case they have in mind is deploying your bot on Azure Functions as a microservice, needless complexity for a small bot that integrates with existing data.
-
None of the sample apps include unit tests. I needed to reverse-engineer some bits from function signatures and captured HTTP traffic.
-
I couldn't find a sample that covered sending a message in the background, not in response to a webhook request. I only managed to figure out how to do this by grepping through the framework code until I found methods that enabled me to do that.
-
The rich text formatting standard, Adaptive Cards, crops message text by default:
Microsoft Teams clients have no way of expanding to see the cropped text. You need to add
"wrap": true
to every text box to allow wrapping. I found this behaviour completely baffling. -
The latest version of Adaptive Cards is 1.6, but Microsoft Teams silently drops messages sent with this version. After some iteration, I found that version 1.4 was the latest one to be accepted at the time of this writing.
-
Documentation is focused on using the Azure Bot Service to configure your bot. After struggling through permission issues with the client's IT team, I created a bot there but couldn't get it to work correctly in Teams. But in the process, I found a link to the Teams Developer Portal, which provides an alternative UI for configuring bots. This worked swimmingly.
I swear this portal wasn't mentioned in any tutorial that I read. I think it is soft-deprecated in favour of Azure Bot Service, but that's a shame because it's so much easier.
-
The bot testing tool, Bot Framework Emulator, is limited to private chats. Most of the work I needed was with messages sent to a channel, so the emulator didn't help past initial testing. I resorted to using production credentials locally and posting to a "testing" channel.
-
I needed channel names to route messages correctly. But the Bot Framework provides channel IDs but not channel names. Fetching the names requires the Microsoft Graph API, with a whole extra package and extra permissions.
I punted on setting all that up and instead opted for a manually configured mapping in a database model. This works fine for the handful of channels in my project.
Despite these setbacks, I got the bot running and the messages flowing. At least the integration seems to be reliable once it is working.
Example Django project
Below is a Microsoft Teams bot in a single-file Django project. I hope it serves you well as a starting point.
The complete code in my client project stores incoming webhook messages, per my webhook receiver post, and extracts channel data for sending background messages. You may want to add that functionality, too.
To test this code:
-
Install Django (5.1.1), botbuilder-core (4.16.1), and aiohttp (3.10.5). (Later versions will probably work.)
-
Save the code in a file called
example.py
. -
Run Django's development server with:
$ python example.py runserver Performing system checks... System check identified no issues (0 silenced). September 04, 2024 - 12:31:58 Django version 5.1.1, using settings None Starting development server at http://127.0.0.1:8000/ Quit the server with CONTROL-C.
-
Install and open Bot Framework Emulator.
-
Connect the emulator to
http://localhost:8000/bot/
: -
Write a message and see the bot respond:
Here's the code:
import json
import os
import sys
from http import HTTPStatus
from asgiref.sync import async_to_sync
from azure.core.exceptions import DeserializationError
from botbuilder.core import (
BotFrameworkAdapter,
BotFrameworkAdapterSettings,
TurnContext,
)
from botbuilder.core.teams import TeamsActivityHandler
from botbuilder.schema import Activity, ActivityTypes, InvokeResponse
from django.conf import settings
from django.http import HttpResponse, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django.core.wsgi import get_wsgi_application
from django.urls import path
class BotHandler(TeamsActivityHandler):
"""
Determines what to do for incoming events with per-category methods.
https://learn.microsoft.com/en-us/microsoftteams/platform/bots/bot-basics?tabs=python
"""
async def on_message_activity(self, turn_context: TurnContext) -> None:
"""
Handle "message activity" events, which correspond to the bot being
directly messaged.
"""
if not turn_context.activity.conversation.is_group:
# Respond to direct messages only.
return await turn_context.send_activity(
Activity(
type=ActivityTypes.message,
text_format="markdown",
text="Beep boop 🤖",
)
)
bot = BotHandler()
bot_adapter = BotFrameworkAdapter(
BotFrameworkAdapterSettings(
# Replace these with settings from environment variables in a real app.
# None values allow requests from the Bot Framework Emulator.
app_id=None,
app_password=None,
)
)
@async_to_sync
async def call_bot(activity: Activity, auth_header: str) -> InvokeResponse | None:
"""Call the bot to respond to an incoming activity."""
return await bot_adapter.process_activity(
activity,
auth_header,
bot.on_turn,
)
# Single-file Django project per:
# https://adamj.eu/tech/2019/04/03/django-versus-flask-with-single-file-applications/
settings.configure(
DEBUG=(os.environ.get("DEBUG", "") == "1"),
# Disable host header validation
ALLOWED_HOSTS=["*"],
# Make this module the urlconf
ROOT_URLCONF=__name__,
# We aren't using any security features but Django requires a secret key
SECRET_KEY="django-insecure-whatever",
)
@csrf_exempt
@require_POST
def webhook(request):
"""
Respond to an event from Microsoft Teams.
"""
if request.content_type != "application/json":
return HttpResponse(status=HTTPStatus.UNSUPPORTED_MEDIA_TYPE)
payload = json.loads(request.body)
# React to the activity
try:
activity = Activity.deserialize(payload)
except DeserializationError:
return HttpResponse(status=HTTPStatus.BAD_REQUEST)
auth_header = request.headers.get("authorization", "")
try:
invoke_response = call_bot(activity, auth_header)
# Note: more more except blocks may be needed, per:
# https://github.com/microsoft/botbuilder-python/blob/main/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py#L19
except TypeError:
response = HttpResponse(status=HTTPStatus.BAD_REQUEST)
else:
if invoke_response:
response = JsonResponse(
data=invoke_response.body, status=invoke_response.status
)
else:
response = JsonResponse({})
return response
urlpatterns = [
path("bot/", webhook),
]
app = get_wsgi_application()
if __name__ == "__main__":
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
Example tests
The code below covers some tests for the bot. They depend on requests-mock to mock the requests sent back by the Bot Framework SDK.
To run the test code, put it in tests.py
next to example.py
, and run:
$ python example.py test
Found 4 test(s).
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 4 tests in 0.024s
OK
The test code:
from http import HTTPStatus
import requests_mock
from django.test import SimpleTestCase
class BotTests(SimpleTestCase):
def setUp(self):
self.mock_requests = self.enterContext(requests_mock.Mocker())
def test_incorrect_content_type(self):
response = self.client.post(
"/bot/",
content_type="text/plain",
)
assert response.status_code == HTTPStatus.UNSUPPORTED_MEDIA_TYPE
def test_post_non_dict(self):
response = self.client.post(
"/bot/",
content_type="application/json",
data=[],
)
assert response.status_code == HTTPStatus.BAD_REQUEST
def test_post_empty_dict(self):
response = self.client.post(
"/bot/",
content_type="application/json",
data={},
)
assert response.status_code == HTTPStatus.BAD_REQUEST
def test_post_message(self):
self.mock_requests.post(
"http://localhost:50096/v3/conversations/19%3Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa%40thread.tacv2%3Bmessageid%3D1111111111111/activities/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
)
# Data based on a payload captured from Bot Framework Emulator
payload = {
"text": "Hi!",
"textFormat": "plain",
"type": "message",
"channelId": "msteams",
"from": {
"id": "82d12900-783f-496d-9449-43dcd216666a",
"name": "User",
"role": "user",
},
"localTimestamp": "2024-09-04T12:05:50+01:00",
"localTimezone": "Europe/London",
"timestamp": "2024-09-04T12:05:50.281Z",
"channelData": {
"channel": {
"id": "19:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@thread.tacv2",
},
},
"conversation": {
"id": "19:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@thread.tacv2;messageid=1111111111111",
},
"id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"recipient": {
"id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
"name": "Bot",
"role": "bot",
},
"serviceUrl": "http://localhost:50096",
}
response = self.client.post(
"/bot/",
content_type="application/json",
data=payload,
)
assert response.status_code == HTTPStatus.OK
assert len(self.mock_requests.request_history) == 1
data = self.mock_requests.request_history[0].json()
assert data["type"] == "message"
assert data["text"] == "Beep boop 🤖"
04 Sep 2024 4:00am GMT
Django Redirects App Tutorial
URL redirects are a fundamental part of maintaining a production website. There are many reasons _why_ you might want to redirect a user from one part of your website to …
04 Sep 2024 12:02am GMT
02 Sep 2024
Django community aggregator: Community blog posts
Why I Still Use Python Virtual Environments in Docker
Whenever I publish something about my Python Docker workflows, I invariably get challenged about whether it makes sense to use virtual environments in Docker containers. As always, it's a trade-off, and I err on the side of standards and predictability.
02 Sep 2024 4:00pm GMT
31 Aug 2024
Django community aggregator: Community blog posts
Django: avoid “useless use of .all()”
Here's a little ORM pet peeve of mine that may deepen your understanding of how QuerySet
s work.
Take this code:
Digger.objects.all().filter(height_cm__gt=200)
The .all()
is unnecessary.
It's equivalent to write:
Digger.objects.filter(height_cm__gt=200)
Why?
The manager, Digger.objects
, already refers to all Digger
objects. Calling .filter()
creates a queryset from that manager, with some filtering. Add .all()
only adds a useless copy of the queryset between these steps.
You only need .all()
in a few cases:
-
To create a queryset that intentionally refers to all objects, perhaps for later filtering or slicing:
diggers = Digger.objects.all() paginator = Paginator(diggers, 50) ...
-
To delete all objects:
Digger.objects.all().delete()
Django requires the
.all()
as confirmation to prevent accidental deletion of a whole table.
Useless calls to .all()
aren't a large problem, but they do mean more code to read and a slight performance cost for the extra queryset copies. I think that avoiding them also shows you understand ORM methods a little bit better.
31 Aug 2024 4:00am GMT