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 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 Definitions
Before implementing context models, you need to understand how the toolkit defines JSON-LD contexts and vocabularies. The Context dataclass from activitypub.contexts defines the structure and semantics of different ActivityPub vocabularies.
The Context Dataclass
A Context instance defines a JSON-LD context document and its associated namespace:
from activitypub.contexts import Context
from rdflib import Namespace
# Define a custom vocabulary namespace
MY_VOCAB = Namespace("https://myapp.example/ns/vocab#")
# Create a context definition
MY_CONTEXT = Context(
url="https://myapp.example/contexts/vocab.jsonld",
namespace=MY_VOCAB,
document={
"@context": {
"myvocab": "https://myapp.example/ns/vocab#",
"customProperty": {
"@id": "myvocab:customProperty",
"@type": "http://www.w3.org/2001/XMLSchema#string"
},
"rating": {
"@id": "myvocab:rating",
"@type": "http://www.w3.org/2001/XMLSchema#integer"
}
}
}
)
Each Context has four fields:
url: The URL where the context document can be fetcheddocument: The JSON-LD context document as a Python dictnamespace: An RDF namespace for creating URIs (optional)content_type: HTTP content type, defaults to "application/ld+json"
Namespaces and Vocabulary Terms
Namespaces provide type-safe access to vocabulary terms:
from activitypub.contexts import AS2, SEC, MASTODON
# Standard ActivityStreams terms
note_type = AS2.Note # https://www.w3.org/ns/activitystreams#Note
content_pred = AS2.content # https://www.w3.org/ns/activitystreams#content
# Security vocabulary terms
public_key = SEC.publicKey # https://w3id.org/security#publicKey
# Platform-specific terms
featured = MASTODON.featured # http://joinmastodon.org/ns#featured
Context Registration
Contexts are registered in Django settings under the FEDERATION configuration. The toolkit loads standard contexts automatically, but you can add custom ones:
FEDERATION = {
# ... other settings ...
'EXTRA_CONTEXTS': {
'myapp.contexts.MY_CONTEXT',
},
}
Once registered, contexts are available through the PRESET_CONTEXTS setting and used during JSON-LD serialization.
How Contexts Enable Vocabulary Extensions
Contexts define how your custom vocabulary terms map to JSON-LD. When your application serializes data, the context document tells other servers how to interpret your custom properties:
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://myapp.example/contexts/vocab.jsonld"
],
"id": "http://myapp.example/objects/123",
"type": "Note",
"content": "A note with custom properties",
"myvocab:customProperty": "custom value",
"myvocab:rating": 5
}
The context document defines the semantics of myvocab:customProperty and myvocab:rating, enabling other ActivityPub implementations to understand your extended vocabulary.
Understanding Context Models
Context models implement the storage and processing layer for specific object types from particular applications or platforms. They translate between RDF graphs and Django's relational model. Context models are composable - each handles only its specific fields without overlapping with other contexts. Multiple context models can process the same reference, each extracting their respective fields.
A context model extends AbstractContextModel and defines:
CONTEXT- Reference to the Context definition (required for proper serialization)LINKED_DATA_FIELDS- Mapping from Django field names to RDF predicatesshould_handle_reference()- Logic to detect when this context applies (discriminates by object type, not namespace presence)- 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. A note might have ObjectContext for basic Note properties and MastodonNoteContext for Mastodon-specific extensions. Your custom context model adds additional vocabulary without interfering with existing contexts. Context models are composable - each handles only its specific fields.
Scenario: Handling Mastodon Notes
Mastodon extends the basic ActivityStreams Note type with platform-specific features like content warnings, visibility settings, and sensitive media flags. The AS2 context handles basic Note properties (type, content, published), while a specialized Mastodon context handles Mastodon-specific extensions. Context models are composable - each handles only its specific fields without overlapping.
First, examine the existing Mastodon context definition:
from activitypub.contexts import MASTODON_CONTEXT, MASTODON, AS2
# The context defines Mastodon's vocabulary extensions
print(MASTODON_CONTEXT.url) # https://docs.joinmastodon.org/spec/activitypub/
print(MASTODON_CONTEXT.namespace) # Namespace('http://joinmastodon.org/ns#')
# Access vocabulary terms
sensitive_pred = MASTODON.sensitive # Mastodon-specific property
blurhash_pred = MASTODON.blurhash # Media preview hash
Now create a context model that handles ONLY Mastodon-specific fields. AS2 fields are handled by other context models:
from django.db import models
from activitypub.models import AbstractContextModel
from activitypub.contexts import MASTODON_CONTEXT, MASTODON, AS2
import rdflib
class MastodonNoteContext(AbstractContextModel):
"""Context model for Mastodon Note objects - handles Mastodon-specific fields only."""
CONTEXT = MASTODON_CONTEXT
LINKED_DATA_FIELDS = {
# Mastodon-specific fields only (don't overlap with AS2 or other contexts)
'sensitive': MASTODON.sensitive,
'atom_uri': MASTODON.atomUri,
'conversation': MASTODON.conversation,
'voters_count': MASTODON.votersCount,
}
# Content moderation fields
sensitive = models.BooleanField(default=False, help_text="Contains sensitive content")
# Mastodon-specific metadata
atom_uri = models.URLField(max_length=500, null=True, blank=True)
conversation = models.URLField(max_length=500, null=True, blank=True)
voters_count = models.IntegerField(null=True, blank=True)
@classmethod
def should_handle_reference(cls, g, reference):
"""Check if this is a Mastodon Note by type + Mastodon-specific fields."""
subject_uri = rdflib.URIRef(reference.uri)
# Must be a Note type (handled by AS2 context)
type_val = g.value(subject=subject_uri, predicate=AS2.type)
if type_val != AS2.Note:
return False
# Must have Mastodon-specific properties to confirm it's from Mastodon
mastodon_fields = (
g.value(subject=subject_uri, predicate=MASTODON.sensitive) or
g.value(subject=subject_uri, predicate=MASTODON.atomUri) or
g.value(subject=subject_uri, predicate=MASTODON.conversation)
)
return mastodon_fields is not None
Registering the Context Model
Add your custom context model to the extra context 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',
'EXTRA_CONTEXT_MODELS': [
'journal.mastodon_context.MastodonNoteContext',
],
}
Run migrations to create the database table:
python manage.py makemigrations
python manage.py migrate
Now when the toolkit processes JSON-LD documents representing Mastodon notes, it automatically creates MastodonNoteContext instances that handle Mastodon-specific fields for that object type.
Testing the Custom Context
Create a test document representing a Mastodon note. In the Django shell:
python manage.py shell
from activitypub.models import LinkedDataDocument, Reference
# Simulate receiving a Mastodon note document
document = {
"id": "https://mastodon.social/users/alice/statuses/123456",
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://docs.joinmastodon.org/spec/activitypub/"
],
"type": "Note",
"content": "<p>Check out this amazing sunset photo! 🌅</p>",
"published": "2025-01-15T18:30:00Z",
"sensitive": True,
"atomUri": "https://mastodon.social/users/alice/statuses/123456",
"conversation": "tag:mastodon.social,2025-01-15:objectId=123456:objectType=Conversation",
"attachment": [
{
"type": "Image",
"url": "https://files.mastodon.social/media/sunset.jpg"
}
]
}
# Process the document
doc = LinkedDataDocument.make(document)
doc.load()
# Check that both contexts were created
ref = Reference.objects.get(uri='https://mastodon.social/users/alice/statuses/123456')
# AS2 context handles basic Note properties
as2_ctx = ref.get_by_context('activitypub.models.ObjectContext')
# Mastodon context handles Mastodon-specific extensions
mastodon_ctx = ref.get_by_context('journal.mastodon_context.MastodonNoteContext')
print(f"Note content: {as2_ctx.content if as2_ctx else 'N/A'}")
print(f"Sensitive: {mastodon_ctx.sensitive}")
print(f"Conversation: {mastodon_ctx.conversation}")
Context models are composable. The AS2 context handles basic Note properties (type, content, published), while the Mastodon context handles only Mastodon-specific extensions like the sensitive flag and conversation threading. Together they represent the complete Mastodon note object without field overlap.
Accessing Custom Context Data
Update your journal entry model to provide access to Mastodon note properties:
from journal.mastodon_context import MastodonNoteContext
class JournalEntry(models.Model):
"""Application model for journal entries."""
reference = models.OneToOneField(Reference, on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE)
# ... other fields ...
@property
def mastodon(self):
"""Access Mastodon note context."""
return self.reference.get_by_context(MastodonNoteContext)
@property
def is_sensitive(self):
"""Check if entry contains sensitive content."""
mastodon_ctx = self.mastodon
return mastodon_ctx.sensitive if mastodon_ctx else False
@property
def conversation_id(self):
"""Get Mastodon conversation ID for threading."""
mastodon_ctx = self.mastodon
return mastodon_ctx.conversation if mastodon_ctx else None
Now you can query entries by their Mastodon properties:
# Find all entries marked as sensitive
from journal.mastodon_context import MastodonNoteContext
sensitive_entries = MastodonNoteContext.objects.filter(sensitive=True)
entries = JournalEntry.objects.filter(
reference__journal_mastodoncontext_context__in=sensitive_entries
)
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.
First, define your custom Context in journal/contexts.py:
from activitypub.contexts import Context
from rdflib import Namespace
# Define your custom namespace
MOOD = Namespace('https://fedjournal.example/ns/mood#')
# Create the context definition
MOOD_CONTEXT = Context(
url='https://fedjournal.example/contexts/mood.jsonld',
namespace=MOOD,
document={
"@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"
}
}
)
Register your custom context in settings:
FEDERATION = {
# ... other settings ...
'EXTRA_CONTEXTS': {
'journal.contexts.MOOD_CONTEXT',
},
}
Now create the context model that implements storage for mood properties in journal/mood_context.py:
from django.db import models
from activitypub.models import AbstractContextModel
from journal.contexts import MOOD, MOOD_CONTEXT
class MoodContext(AbstractContextModel):
"""Context model for mood tracking vocabulary."""
CONTEXT = MOOD_CONTEXT
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 ...
'EXTRA_CONTEXT_MODELS': [
'journal.mood_context.MoodContext',
],
}
Run migrations:
python manage.py makemigrations
python manage.py migrate
Creating Entries with Mastodon Context
You can create journal entries that include Mastodon-specific metadata. This allows entries to federate with Mastodon servers while preserving platform-specific features like content warnings:
from journal.mastodon_context import MastodonNoteContext
class JournalEntry(models.Model):
# ... existing code ...
@classmethod
def create_entry(cls, user, content, entry_type=EntryType.PERSONAL,
title=None, duration=None, sensitive=False):
"""Create a journal entry with optional Mastodon context."""
# 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 Mastodon context if entry is marked sensitive
if sensitive:
MastodonNoteContext.objects.create(
reference=reference,
sensitive=True,
)
# Create application entry
entry = cls.objects.create(
reference=reference,
user=user,
entry_type=entry_type,
)
return entry
Creating Entries with Mood Tracking
Beyond handling existing platform vocabularies, you can also create domain-specific vocabularies. The following example shows how to add mood tracking to journal entries.
Update the entry creation method to include both Mastodon and mood data:
from journal.mood_context import MoodContext
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."""
# 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."""
return self.reference.get_by_context(MoodContext)
Update the admin form to include both sensitive content flag and 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)
sensitive = forms.BooleanField(required=False, help_text="Mark as sensitive content")
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 Mastodon and 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 ...
sensitive = form.cleaned_data.get('sensitive', False)
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,
sensitive=sensitive,
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 ...
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.
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 definitions and their corresponding context models for both existing platform extensions and entirely new vocabularies. Context definitions establish the vocabulary semantics, while context models provide the storage and processing implementation.
The pattern applies universally: define your Context with namespace and document, register it in settings, create the context model with field mappings, implement detection logic, register the model, 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.
To learn how to present your custom context data to remote viewers when they fetch your objects, see the Publishing to the Fediverse tutorial, which covers projections for controlling JSON-LD output.
The next tutorial covers handling incoming activities, where you will learn to process federated actions that arrive at your server's inbox.