Your editorial team publishes weekly PDF reports, downloadable whitepapers, and legal documents. Currently every file requires a developer to upload and link it. This lesson teaches you to leverage Wagtail's Documents and Collections system for centralized, permission-controlled document management.
1. Learning Objectives
By the end of this lesson, you will be able to:
- Explain what Wagtail Documents are and how they differ from images and other media
- Create and organize documents using Wagtail Collections
- Add document choosers to page models and StreamField blocks
- Control access to documents using collection-level permissions and privacy settings
- Serve and render documents in templates with custom styling
- Manage documents programmatically through Python and the REST API
- Implement document upload workflows and bulk operations
2. Why This Matters (Real-World Scenario)
Your editorial team publishes weekly PDF reports, downloadable whitepapers, press kit assets, and legal documents on your Wagtail-powered corporate site. Currently, every new document requires a developer to upload it via the admin and manually add a link to the page. The design team needs to organize assets by campaign, the legal team needs restricted access to confidential documents, and the marketing team wants self-service uploads for case studies. Without a proper document management system, files end up scattered across shared drives, Google Docs, and the server's media folder. This lesson teaches you to leverage Wagtail's built-in Documents and Collections system to give your team a centralized, permission-controlled, and template-friendly document repository.
3. Core Concepts
Documents — In Wagtail, a Document is any uploaded file (PDF, DOCX, XLSX, ZIP, images as non-image assets) stored in the media directory with a database record. Each Document has a title, file reference, file size, upload timestamp, and optional tags. Documents are served through Wagtail's serve view, which enforces privacy settings.
Collections — Collections are hierarchical folders that organize both images and documents. They provide a way to group related files by campaign, department, project, or any logical grouping. Collections can be nested (parent/child) and support per-collection group permissions, enabling fine-grained access control.
DocumentChooserBlock — A StreamField block type that lets content editors pick a document from the Wagtail admin chooser modal. It stores a foreign key to the Document model and exposes the chosen document in templates.
Collection Privacy & Permissions — Each collection has configurable privacy settings: public (anyone), private (logged-in users), or group-specific access (certain Django groups). Document serve views respect these settings, blocking unauthorized downloads at the URL level.
Document Serving — Wagtail serves documents through a dedicated view at /documents/{id}/{filename}. This view checks privacy settings, increments download counts, and can be overridden for custom serve logic (e.g., tracking downloads, watermarking PDFs).
4. Hands-On Practice: Step-by-Step Document Management
4.1 Setting Up the Document Model
Wagtail's Document model comes built-in — no additional models required. Ensure your project's INSTALLED_APPS includes wagtail.documents (it's there by default in a Wagtail project):
# myproject/settings/base.py
INSTALLED_APPS = [
# ... other apps
'wagtail.documents',
# ...
]
# Document storage (default: local media/documents/)
# For production, configure AWS S3 or Google Cloud Storage
# DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
4.2 Creating Collections in the Admin
Navigate to Settings → Collections in the Wagtail admin sidebar. You'll see a default Root collection. Create a hierarchy like this:
- Root (default, all files)
- ├── Marketing (brochures, case studies, logos)
- │ ├── 2026 Campaign (Q1-Q4 campaign assets)
- │ └── Brand Assets (logos, style guides)
- ├── Legal (contracts, terms of service, privacy policies)
- ├── Technical (API docs, white papers, architecture diagrams)
- └── Press (press releases, media kits, executive photos)
Each collection can have its own set of permissions. Set Legal to private, accessible only by the Legal group. Set Marketing to private but accessible by the Marketing and Editors groups.
# You can also create collections programmatically:
from wagtail.documents import get_document_model
from wagtail.models import Collection
root = Collection.get_first_root_node()
marketing = root.add_child(name='Marketing')
legal = root.add_child(name='Legal')
marketing_2026 = marketing.add_child(name='2026 Campaign')
print(f'Created collections: {root}, {marketing}, {legal}, {marketing_2026}')
4.3 Uploading and Managing Documents
Go to Documents in the Wagtail admin. Click Add Document.
Fill in:
- File — Choose the file to upload
- Title — Display name (auto-filled from filename)
- Collection — Pick the target collection from the dropdown
- Tags — Optional keywords for filtering
- File size — Auto-populated after upload
Wagtail stores the file at media/documents/{year}/{month}/{filename} by default. The database record tracks the original filename, title, upload date, and collection membership.
# Upload a document programmatically:
from wagtail.documents import get_document_model
from django.core.files.base import ContentFile
Document = get_document_model()
from wagtail.models import Collection
marketing_collection = Collection.objects.get(name='Marketing')
# Create a document from a file-like object
doc = Document(
title='2026 Product Brochure',
collection=marketing_collection,
)
doc.file.save('brochure-2026.pdf', ContentFile(b'%PDF-1.4...'), save=False)
doc.save()
print(f'Document created: #{doc.id} - {doc.title} ({doc.file.size} bytes)')
4.4 Adding DocumentChooserBlock to Page Models
The most common integration is adding a document chooser to a page model. Use DocumentChooserBlock in your StreamField definition or as a direct field:
# myapp/models.py
from wagtail.fields import StreamField
from wagtail.documents.blocks import DocumentChooserBlock
from wagtail.models import Page
class ResourcePage(Page):
body = StreamField([
('heading', blocks.CharBlock()),
('paragraph', blocks.RichTextBlock()),
('document', DocumentChooserBlock()),
('document_list', blocks.ListBlock(DocumentChooserBlock())),
], use_json_field=True)
content_panels = Page.content_panels + [
FieldPanel('body'),
]
4.5 Rendering Documents in Templates
When you use DocumentChooserBlock, the template receives the Document model instance. Render it with a download link:
{% load wagtailcore_tags %}
{% load wagtailimages_tags %}
{# Single document #}
{% for block in page.body %}
{% if block.block_type == 'document' %}
<div class="document-download">
<a href="{{ block.value.url }}" class="btn btn-download" download>
<i class="icon-download"></i>
{{ block.value.title }}
<span class="file-meta">
({{ block.value.file_extension|upper }},
{{ block.value.file.size|filesizeformat }})
</span>
</a>
</div>
{% endif %}
{% endfor %}
{% load wagtailcore_tags %}
{# Iterating a list of documents #}
{% for block in page.body %}
{% if block.block_type == 'document_list' %}
<ul class="document-list">
{% for doc in block.value %}
<li>
<a href="{{ doc.url }}">
{{ doc.title }}
</a>
<span class="file-size">{{ doc.file.size|filesizeformat }}</span>
<span class="file-date">{{ doc.created_at|date:'M j, Y' }}</span>
</li>
{% endfor %}
</ul>
{% endif %}
{% endfor %}
4.6 Document Privacy and Collection Permissions
Wagtail supports two levels of document access control:
Collection-level permissions — Configured under Settings → Collections → Edit → Privacy. Options:
- Public — Accessible to anyone with the URL
- Private (Login required) — Must be authenticated on the site
- Group-specific — Only members of specific Django groups can view/download
# Set collection privacy programmatically:
from wagtail.models import Collection, GroupCollectionPermission
from django.contrib.auth.models import Group
legal_collection = Collection.objects.get(name='Legal')
editors_group = Group.objects.get(name='Editors')
# Grant the Editors group 'choose' and 'add' permissions on Legal collection
from wagtail.documents.permissions import permission_policy
permission_policy = permission_policy
permission_policy.add_permission(editors_group, 'add', legal_collection)
permission_policy.add_permission(editors_group, 'choose', legal_collection)
print(f'Permissions set for {editors_group.name} on {legal_collection.name}')
4.7 Custom Document Serve Logic
Override Wagtail's built-in document serve view to add download tracking, authentication checks, or custom headers:
# myapp/views.py
from wagtail.documents.views.serve import serve
from django.http import HttpResponse
import logging
logger = logging.getLogger(__name__)
def custom_document_serve(request, document_id, document_filename):
"""Custom document serve view with download tracking."""
from wagtail.documents import get_document_model
Document = get_document_model()
doc = Document.objects.get(id=document_id)
# Log download
logger.info(f'Document downloaded: {doc.title} by {request.user}')
# Track download count (add a custom field)
doc.download_count += 1
doc.save(update_fields=['download_count'])
# Pass to default serve view for privacy checks and serving
return serve(request, document_id, document_filename)
# myproject/urls.py
from django.urls import re_path
from myapp.views import custom_document_serve
from wagtail.documents.views.serve import serve
urlpatterns = [
# Override the default document serve URL
re_path(
r'^documents/(\d+)/(.*)$',
custom_document_serve,
name='wagtail_documents_serve_custom'
),
# ... other URL patterns
]
4.8 Document Management via REST API
Wagtail's REST API exposes Documents through a dedicated endpoint. You can list, filter, create, and update documents programmatically:
# List documents via REST API
curl -s "http://localhost:8000/api/v2/documents/" | python3 -m json.tool
# Filter by collection
curl -s "http://localhost:8000/api/v2/documents/?collection_id=3"
# Search documents by title
curl -s "http://localhost:8000/api/v2/documents/?search=brochure"
# Upload a document via REST API (authenticated):
import requests
url = 'http://localhost:8000/api/v2/documents/'
token = 'YOUR_API_TOKEN'
headers = {'Authorization': f'Token {token}'}
files = {
'file': open('report-q2.pdf', 'rb'),
}
data = {
'title': 'Q2 Financial Report',
'collection': 5, # ID of the Legal collection
}
response = requests.post(url, headers=headers, files=files, data=data)
print(response.json())
4.9 Bulk Document Operations
For migrating existing files into Wagtail, write a management command that scans a directory and imports documents:
# myapp/management/commands/import_documents.py
from django.core.management.base import BaseCommand
from wagtail.documents import get_document_model
from wagtail.models import Collection
from django.core.files import File
import os
Document = get_document_model()
class Command(BaseCommand):
help = 'Import documents from a directory into a collection'
def add_arguments(self, parser):
parser.add_argument('directory', type=str)
parser.add_argument('--collection', type=str, default='Root')
parser.add_argument('--recursive', action='store_true')
def handle(self, *args, **options):
directory = options['directory']
collection = Collection.objects.get(name=options['collection'])
for root, dirs, files in os.walk(directory):
for filename in files:
filepath = os.path.join(root, filename)
with open(filepath, 'rb') as f:
doc = Document(
title=os.path.splitext(filename)[0].replace('-', ' ').title(),
collection=collection,
)
doc.file.save(filename, File(f), save=True)
self.stdout.write(f'Imported: {filename}')
if not options['recursive']:
break
# Run the import command
python manage.py import_documents /path/to/pdf/files --collection Marketing --recursive
5. Common Errors & Solutions
Error: Document URL returns 403 Forbidden
The document is in a private collection and the request is unauthenticated. Solution: Set the collection privacy to Public if the document should be accessible to everyone, or ensure users are logged in when accessing the document URL.
Error: DocumentChooserBlock returns None in template
The document was deleted but the block still references it. Solution: Add null handling in templates — {% if block.value %} — and set up a signal handler to clean up orphaned blocks when documents are deleted.
Error: File size exceeds upload limit
Django/Wagtail enforces DATA_UPLOAD_MAX_MEMORY_SIZE (default 2.5MB) and FILE_UPLOAD_MAX_MEMORY_SIZE. Solution: Increase these in settings or configure chunked uploads for large files. Also set Nginx/Apache client_max_body_size limits accordingly.
Error: Document upload fails silently in production
When using S3/GCS storage, file permissions or bucket policies may prevent writes. Solution: Check storage backend credentials, bucket CORS settings, and the Django storage backend configuration. Enable Django's logging for django.request to see the actual error.
Error: Collection chooser shows no collections
The user doesn't have 'choose' permission on any collection. Solution: Grant 'choose' document permission for the relevant collections to the user's group via Settings → Collections → Edit → Group Permissions.
6. Summary Checklist
- [ ] Created hierarchical Collections for organizing documents (Marketing, Legal, Technical, Press)
- [ ] Set collection-level privacy and group permissions for restricted access
- [ ] Uploaded documents via admin interface and verified download links work
- [ ] Added DocumentChooserBlock to a page model for editor-friendly document selection
- [ ] Rendered document download cards in templates with file size and type information
- [ ] Implemented a custom document serve view for download tracking
- [ ] Tested REST API document endpoints for programmatic access
- [ ] Ran bulk import for existing documents into appropriate collections
- [ ] Verified private documents return 403 for unauthenticated users
- [ ] Set appropriate file upload size limits in settings
7. Practice Exercise
Challenge: Build a Press Kit Page with Access-Controlled Documents
Create a new Wagtail page type called PressKitPage with the following requirements:
- Add a StreamField with heading, paragraph, and a document list block that uses
DocumentChooserBlock - Create collections named Press Kit 2026 with sub-collections: Logos, Screenshots, Press Releases
- Set the Press Releases sub-collection to private, accessible only by the Press group
- Upload at least one document to each sub-collection
- Build a template that renders documents grouped by collection, with download buttons showing file type icon, file size, and upload date
- Write a test that verifies: (a) public documents are accessible without authentication, and (b) private collection documents return 403 for anonymous requests
Bonus: Write a management command that generates a JSON manifest of all documents in a collection, including their URLs, file sizes, and tags — useful for CDN preloading or sitemap generation.
8. Next Steps
You now have a complete document management system built into your Wagtail site — with organized collections, permission-controlled access, template-ready document rendering, and a custom download tracker. Your editorial team can upload and manage documents independently, and your legal team has confidence that sensitive files are properly restricted.
Up next: Wagtail Hooks & Custom Admin Views — Learn to extend Wagtail's admin interface with custom panels, buttons, and views. You'll build a document usage report panel that shows which pages reference each document, a bulk tag editor for documents, and a custom admin view for document upload analytics. These hooks transform Wagtail from a CMS into a tailored content platform for your team's specific workflows.
Further resources:
- Wagtail Documents Documentation — Official guide to document management
- Wagtail Collections Documentation — Detailed collection configuration
- Wagtail Hooks Reference — For building custom admin views
- Wagtail Privacy Documentation — Advanced privacy settings for pages and collections
Common pitfalls to avoid:
- Don't store documents in the default Root collection — always organize into named collections from day one. Renaming/reorganizing later is painful.
- Avoid hardcoding document URLs in templates — use
{{ doc.url }}which respects privacy settings and handles filename changes. - Don't forget to grant 'choose' permission — without it, editors can upload documents but can't select them in chooser modals.
- Never skip privacy testing — a misconfigured collection privacy setting can expose confidential documents to the public. Always test with an incognito browser session.
Comments (0)
This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!
ReplyLeave a Comment