Creating Custom Context Models
This tutorial teaches you how to extend Django ActivityPub Toolkit with custom context models for specialized vocabularies. You will learn to handle vocabulary extensions from platforms like Mastodon and Lemmy, and create entirely new vocabularies for your domain-specific needs.
By the end of this tutorial, you will understand how to map RDF predicates to Django fields, implement context detection logic, and integrate custom contexts into the toolkit's processing pipeline.
Understanding Context Models
Context models translate between RDF graphs and Django's relational model. Each context model represents a specific vocabulary or namespace. The toolkit includes context models for ActivityStreams 2.0 (AS2) and Security Vocabulary v1 (SECv1). Your applications can add context models for any vocabulary.
A context model extends AbstractContextModel and defines:
NAMESPACE- The RDF namespace URILINKED_DATA_FIELDS- Mapping from Django field names to RDF predicatesshould_handle_reference()- Logic to detect when this context applies- Django fields for storing vocabulary data
Multiple context models can attach to the same reference. An actor might have ActorContext for AS2 properties and SecV1Context for cryptographic keys. Your custom context adds additional vocabulary without interfering with existing contexts.
Scenario: Adding Mastodon Extensions
Mastodon extends ActivityPub with several custom properties. The featured property links to a collection of pinned posts. The sensitive flag marks content requiring warnings. These extensions use Mastodon's vocabulary namespace.
Create a context model for Mastodon extensions in a new file journal/mastodon_context.py:
from django.db import models
import rdflib
from activitypub.models import AbstractContextModel, ReferenceField
# Define Mastodon's namespace
MASTODON = rdflib.Namespace('http://joinmastodon.org/ns#')
class MastodonContext(AbstractContextModel):
"""Context model for Mastodon-specific extensions."""
NAMESPACE = str(MASTODON)
LINKED_DATA_FIELDS = {
'featured': MASTODON.featured,
'sensitive': MASTODON.sensitive,
}
# Featured collection - posts the actor has pinned
featured = models.ForeignKey(
'activitypub.Reference',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='mastodon_featured_by'
)
# Content warning flag
sensitive = models.BooleanField(default=False)
@classmethod
def should_handle_reference(cls, g, reference):
"""Check if this reference has Mastodon properties."""
subject_uri = rdflib.URIRef(reference.uri)
# Check for any Mastodon predicate
featured_val = g.value(subject=subject_uri, predicate=MASTODON.featured)
sensitive_val = g.value(subject=subject_uri, predicate=MASTODON.sensitive)
return featured_val is not None or sensitive_val is not None
class Meta:
verbose_name = 'Mastodon Context'
verbose_name_plural = 'Mastodon Contexts'
The LINKED_DATA_FIELDS dictionary maps Django field names to RDF predicates. When processing a graph, the toolkit walks through this mapping and extracts values for each predicate.
The should_handle_reference() method determines whether this context applies to a reference. Check if the graph contains any predicates from your vocabulary. Return True if the context should process this reference, False otherwise.
Registering the Context Model
Add your custom context to the autoloaded models list in config/settings.py:
FEDERATION = {
'DEFAULT_URL': 'http://localhost:8000',
'SOFTWARE_NAME': 'FedJournal',
'SOFTWARE_VERSION': '0.1.0',
'ACTOR_VIEW': 'journal:actor',
'OBJECT_VIEW': 'journal:entry-detail',
'AUTOLOADED_CONTEXT_MODELS': [
'activitypub.models.LinkContext',
'activitypub.models.ObjectContext',
'activitypub.models.ActorContext',
'activitypub.models.ActivityContext',
'activitypub.models.EndpointContext',
'activitypub.models.QuestionContext',
'activitypub.models.CollectionContext',
'activitypub.models.CollectionPageContext',
'activitypub.models.SecV1Context',
'journal.mastodon_context.MastodonContext',
],
}
Run migrations to create the database table:
python manage.py makemigrations
python manage.py migrate
Now when the toolkit processes JSON-LD documents with Mastodon properties, it automatically creates MastodonContext instances.
Testing the Custom Context
Create a test document with Mastodon properties. In the Django shell:
python manage.py shell
from activitypub.models import LinkedDataDocument, Reference
# Simulate receiving a document with Mastodon extensions
document = {
"id": "https://mastodon.social/@alice/123456",
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"toot": "http://joinmastodon.org/ns#",
"featured": {"@id": "toot:featured", "@type": "@id"},
"sensitive": "toot:sensitive"
}
],
"type": "Note",
"content": "This is a test post",
"published": "2025-01-15T10:00:00Z",
"sensitive": True
}
# Process the document
doc = LinkedDataDocument.make(document)
doc.load()
# Check that both contexts were created
ref = Reference.objects.get(uri='https://mastodon.social/@alice/123456')
obj_ctx = ref.get_by_context('activitypub.models.ObjectContext')
mastodon_ctx = ref.get_by_context('journal.mastodon_context.MastodonContext')
print(f"Object content: {obj_ctx.content}")
print(f"Sensitive flag: {mastodon_ctx.sensitive}")
The same reference now has two contexts. ObjectContext handles standard AS2 properties. MastodonContext handles Mastodon extensions. They coexist without conflict.
Accessing Custom Context Data
Update your journal entry model to provide access to Mastodon properties:
class JournalEntry(models.Model):
# ... existing fields ...
@property
def mastodon(self):
"""Access Mastodon-specific context."""
from journal.mastodon_context import MastodonContext
return self.reference.get_by_context(MastodonContext)
@property
def is_sensitive(self):
"""Check if content is marked sensitive."""
mastodon_ctx = self.mastodon
return mastodon_ctx.sensitive if mastodon_ctx else False
Now you can query entries by their Mastodon properties:
# Find all sensitive entries
from journal.mastodon_context import MastodonContext
sensitive_contexts = MastodonContext.objects.filter(sensitive=True)
sensitive_entries = JournalEntry.objects.filter(
reference__mastodon_mastodoncontext_context__in=sensitive_contexts
)
Creating Domain-Specific Vocabularies
Beyond handling existing vocabularies, you can create entirely new vocabularies for your domain. Suppose you want to extend journal entries with mood tracking.
Define a custom vocabulary namespace and context model in journal/mood_context.py:
from django.db import models
import rdflib
from activitypub.models import AbstractContextModel
# Define your custom namespace
MOOD = rdflib.Namespace('https://fedjournal.example/ns/mood#')
class MoodContext(AbstractContextModel):
"""Context model for mood tracking vocabulary."""
NAMESPACE = str(MOOD)
CONTEXT_URL = 'https://fedjournal.example/contexts/mood.jsonld'
LINKED_DATA_FIELDS = {
'mood_level': MOOD.level,
'mood_type': MOOD.type,
'mood_notes': MOOD.notes,
}
class MoodLevel(models.IntegerChoices):
VERY_LOW = 1, 'Very Low'
LOW = 2, 'Low'
NEUTRAL = 3, 'Neutral'
HIGH = 4, 'High'
VERY_HIGH = 5, 'Very High'
class MoodType(models.TextChoices):
HAPPY = 'happy', 'Happy'
SAD = 'sad', 'Sad'
ANXIOUS = 'anxious', 'Anxious'
CALM = 'calm', 'Calm'
ENERGETIC = 'energetic', 'Energetic'
TIRED = 'tired', 'Tired'
mood_level = models.IntegerField(
choices=MoodLevel.choices,
null=True,
blank=True
)
mood_type = models.CharField(
max_length=20,
choices=MoodType.choices,
null=True,
blank=True
)
mood_notes = models.TextField(blank=True)
@classmethod
def should_handle_reference(cls, g, reference):
"""Check if this reference has mood properties."""
subject_uri = rdflib.URIRef(reference.uri)
# Check for any mood predicate
level_val = g.value(subject=subject_uri, predicate=MOOD.level)
type_val = g.value(subject=subject_uri, predicate=MOOD.type)
return level_val is not None or type_val is not None
class Meta:
verbose_name = 'Mood Context'
verbose_name_plural = 'Mood Contexts'
Register it in settings:
FEDERATION = {
# ... other settings ...
'AUTOLOADED_CONTEXT_MODELS': [
# ... standard contexts ...
'journal.mastodon_context.MastodonContext',
'journal.mood_context.MoodContext',
],
}
Run migrations:
python manage.py makemigrations
python manage.py migrate
Creating Entries with Custom Context
Update the entry creation method to include mood data:
class JournalEntry(models.Model):
# ... existing code ...
@classmethod
def create_entry(cls, user, content, entry_type=EntryType.PERSONAL,
title=None, duration=None, mood_level=None, mood_type=None):
"""Create a journal entry with mood tracking."""
from journal.mood_context import MoodContext
# Generate reference and create AS2 context
domain = Domain.get_default()
reference = ObjectContext.generate_reference(domain)
obj_context = ObjectContext.make(
reference=reference,
type=ObjectContext.Types.NOTE,
content=content,
name=title,
published=timezone.now(),
duration=duration,
)
# Create mood context if mood data provided
if mood_level is not None or mood_type is not None:
MoodContext.objects.create(
reference=reference,
mood_level=mood_level,
mood_type=mood_type,
)
# Create application entry
entry = cls.objects.create(
reference=reference,
user=user,
entry_type=entry_type,
)
return entry
@property
def mood(self):
"""Access mood tracking context."""
from journal.mood_context import MoodContext
return self.reference.get_by_context(MoodContext)
Update the admin form to include mood fields:
class JournalEntryForm(forms.Form):
user = forms.ModelChoiceField(queryset=User.objects.all(), required=True)
entry_type = forms.ChoiceField(choices=JournalEntry.EntryType.choices, required=True)
title = forms.CharField(max_length=200, required=False)
content = forms.CharField(widget=forms.Textarea, required=True)
duration_minutes = forms.IntegerField(required=False, min_value=0)
mood_level = forms.ChoiceField(
choices=[('', '---')] + list(MoodContext.MoodLevel.choices),
required=False
)
mood_type = forms.ChoiceField(
choices=[('', '---')] + list(MoodContext.MoodType.choices),
required=False
)
# Update the changelist_view to handle mood data
def changelist_view(self, request, extra_context=None):
if request.method == 'POST':
form = JournalEntryForm(request.POST)
if form.is_valid():
# ... existing duration handling ...
mood_level = form.cleaned_data.get('mood_level')
mood_type = form.cleaned_data.get('mood_type')
JournalEntry.create_entry(
user=form.cleaned_data['user'],
content=form.cleaned_data['content'],
entry_type=form.cleaned_data['entry_type'],
title=form.cleaned_data['title'] or None,
duration=duration,
mood_level=int(mood_level) if mood_level else None,
mood_type=mood_type if mood_type else None,
)
self.message_user(request, 'Journal entry created successfully')
# ... rest of method ...
Serializing Custom Context
When serving entries with custom context, the serializer automatically includes all contexts. However, you need to publish your vocabulary's JSON-LD context document so other servers can understand your terms.
Create a context document that defines your vocabulary. This would typically be served at CONTEXT_URL:
{
"@context": {
"mood": "https://fedjournal.example/ns/mood#",
"level": {
"@id": "mood:level",
"@type": "http://www.w3.org/2001/XMLSchema#integer"
},
"type": {
"@id": "mood:type",
"@type": "@id"
},
"notes": "mood:notes"
}
}
When your entries serialize, they include this context and the mood properties become part of the JSON-LD output:
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://fedjournal.example/contexts/mood.jsonld"
],
"id": "http://localhost:8000/entries/1",
"type": "Note",
"content": "Had a great workout today!",
"published": "2025-01-15T10:00:00Z",
"duration": "PT45M",
"mood:level": 5,
"mood:type": "energetic"
}
Other servers might not understand your mood vocabulary, but they can still process the standard AS2 properties. Servers that do implement mood tracking can extract and use that data.
Querying Across Contexts
Custom contexts integrate with Django's ORM, enabling complex queries across vocabularies:
from django.db.models import Q
from journal.mood_context import MoodContext
from activitypub.models import ObjectContext
# Find energetic entries over 30 minutes
energetic_long_entries = JournalEntry.objects.filter(
reference__activitypub_objectcontext_context__duration__gt=timedelta(minutes=30),
reference__journal_moodcontext_context__mood_type=MoodContext.MoodType.ENERGETIC
)
# Find sensitive entries with low mood
sensitive_low_mood = JournalEntry.objects.filter(
reference__journal_mastodoncontext_context__sensitive=True,
reference__journal_moodcontext_context__mood_level__lte=MoodContext.MoodLevel.LOW
)
# Aggregate mood statistics
from django.db.models import Count
mood_distribution = MoodContext.objects.values('mood_type').annotate(
count=Count('id')
).order_by('-count')
The ability to query across contexts while maintaining vocabulary separation is a key advantage of this architecture.
Custom Serializers for Context Models
If your context needs specialized serialization logic, create a custom serializer and register it:
# journal/serializers.py
from activitypub.serializers import ContextModelSerializer
class MoodContextSerializer(ContextModelSerializer):
def show_mood_notes(self, instance, viewer):
"""Only show mood notes to the entry's author."""
entry = instance.reference.journal_entry
return viewer and viewer == entry.user.reference
Register it in settings:
FEDERATION = {
# ... other settings ...
'CUSTOM_SERIALIZERS': {
'journal.mood_context.MoodContext': 'journal.serializers.MoodContextSerializer',
},
}
Now mood notes only appear in serialized output when the viewer is the author.
Best Practices
Namespace carefully. Use URIs you control for custom vocabularies. Include your domain name to ensure global uniqueness. Document your vocabulary so others can implement it.
Check conservatively. The should_handle_reference() method should only return True when you're confident the data belongs to your vocabulary. Avoid claiming references that other contexts might handle.
Handle missing data gracefully. Not every reference will have your context. Check for None when accessing custom context properties. Provide sensible defaults.
Version your vocabulary. If you change predicate meanings or add breaking changes, use a new namespace version. This allows gradual migration without breaking existing implementations.
Test with real data. Fetch documents from servers that use the vocabularies you're extending. Verify your context model correctly extracts data from real-world JSON-LD.
Summary
You have learned to create custom context models for both existing platform extensions and entirely new vocabularies. Context models map RDF predicates to Django fields, enabling relational queries over federated data.
The pattern applies universally: define the namespace, map predicates to fields, implement detection logic, register the context, and run migrations. Multiple contexts coexist on references, each handling its vocabulary independently.
Custom contexts enable applications to extend ActivityPub without forking the protocol. Your mood tracking vocabulary might gain adoption. Other servers implementing it can federate mood data with yours. This is how the Fediverse evolves—through vocabulary extension rather than protocol modification.
The next tutorial covers handling incoming activities, where you will learn to process federated actions that arrive at your server's inbox.