Bringing OData Standards to Django - A comprehensive Django package that implements the OData (Open Data Protocol) specification for REST APIs, enabling standardized data access patterns with powerful querying capabilities.
This package transforms your Django models into OData-compliant endpoints by seamlessly integrating drf-flex-fields and odata-query, providing enterprise-grade API functionality with minimal configuration.
- Complete OData v4 Query Support: Full implementation of OData query options (
$filter,$orderby,$top,$skip,$select,$expand,$count) - OData Response Format: Standards-compliant JSON responses with proper
@odata.contextand metadata annotations - Service Metadata: Built-in
$metadataendpoint for complete API discovery and client generation - OData Error Handling: Standardized error responses following OData specifications
- Intelligent Query Optimization: Automatic
select_related()andprefetch_related()application to prevent N+1 queries - Smart Query Translation: OData filter expressions automatically converted to optimized Django ORM queries
- Efficient Data Loading: Only requested fields are serialized and transmitted
- Minimal Configuration: Transform existing Django models into OData endpoints with just a few lines of code
- Django REST Framework Integration: Seamlessly extends DRF viewsets and serializers
- Type Safety: Proper OData-to-Django field type mapping for all Django field types
- Flexible Architecture: Easy to customize and extend for specific business requirements
pip install django-odataOr install from source:
git clone https://github.com/dev-muhammad/django-odata.git
cd django-odata
pip install -e .- Django >= 4.2 LTS
- Python >= 3.8
- djangorestframework >= 3.12.0
- drf-flex-fields >= 1.0.0
- odata-query >= 0.9.0
Note: Django 4.2 LTS is supported until April 2026. Please verify that drf-flex-fields supports Django 4.2 in your environment, as compatibility may vary between versions.
INSTALLED_APPS = [
# ... your other apps
'rest_framework',
'rest_flex_fields',
'django_odata',
]from django_odata.serializers import ODataModelSerializer
from .models import BlogPost, Author, Category
class AuthorSerializer(ODataModelSerializer):
class Meta:
model = Author
fields = ['id', 'name', 'email', 'bio']
class CategorySerializer(ODataModelSerializer):
class Meta:
model = Category
fields = ['id', 'name', 'description']
class BlogPostSerializer(ODataModelSerializer):
class Meta:
model = BlogPost
fields = ['id', 'title', 'content', 'status', 'created_at']
expandable_fields = {
'author': (AuthorSerializer, {}),
'categories': (CategorySerializer, {'many': True}),
}from django_odata.viewsets import ODataModelViewSet
from .models import BlogPost, Author, Category
from .serializers import BlogPostSerializer, AuthorSerializer, CategorySerializer
class BlogPostViewSet(ODataModelViewSet):
queryset = BlogPost.objects.all()
serializer_class = BlogPostSerializer
class AuthorViewSet(ODataModelViewSet):
queryset = Author.objects.all()
serializer_class = AuthorSerializer
class CategoryViewSet(ODataModelViewSet):
queryset = Category.objects.all()
serializer_class = CategorySerializerfrom django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import BlogPostViewSet, AuthorViewSet, CategoryViewSet
router = DefaultRouter()
router.register(r'posts', BlogPostViewSet)
router.register(r'authors', AuthorViewSet)
router.register(r'categories', CategoryViewSet)
urlpatterns = [
path('odata/', include(router.urls)),
]# Get all blog posts
GET /odata/posts/
# Get a specific blog post
GET /odata/posts/1/
# Get first 10 posts
GET /odata/posts/?$top=10
# Skip first 20 posts, get next 10
GET /odata/posts/?$skip=20&$top=10# Get published posts
GET /odata/posts/?$filter=status eq 'published'
# Get posts with more than 100 views
GET /odata/posts/?$filter=view_count gt 100
# Get posts created this year
GET /odata/posts/?$filter=year(created_at) eq 2024
# Complex filter
GET /odata/posts/?$filter=status eq 'published' and view_count gt 50# Sort by creation date (newest first)
GET /odata/posts/?$orderby=created_at desc
# Sort by title alphabetically
GET /odata/posts/?$orderby=title asc
# Multiple sort criteria
GET /odata/posts/?$orderby=status desc,created_at desc# Select specific fields (OData standard)
GET /odata/posts/?$select=id,title,status
# If no $select specified, returns all available fields
GET /odata/posts/
# Omit specific fields (legacy feature)
GET /odata/posts/?omit=content# Include author information (automatically adds 'author' to selected fields)
GET /odata/posts/?$expand=author
# Include multiple related fields
GET /odata/posts/?$expand=author,categories
# When using $expand, expanded fields are automatically selected
GET /odata/posts/?$expand=author
# Returns: all fields + author (with expanded data)
# Explicit field selection with expansion
GET /odata/posts/?$select=id,title&$expand=author
# Returns: id, title, author (with expanded data)
# Nested field selection in expanded properties (OData standard)
GET /odata/posts/?$expand=author($select=name,bio)
# Returns: all fields + author (with only name and bio)
# Multiple nested expansions
GET /odata/posts/?$expand=author($select=name,bio),categories($select=id,name)
# Returns: all fields + author (name,bio) + categories (id,name)
# Mixed simple and nested expansions
GET /odata/posts/?$expand=author($select=name),categories,tags($select=name)
# Returns: all fields + author (name only) + categories (all fields) + tags (name only)
# Combine explicit selection with nested expansions
GET /odata/posts/?$select=id,title&$expand=author($select=name,bio)
# Returns: id, title, author (with name and bio only)The package automatically optimizes database queries when using $expand to prevent N+1 query problems:
# This request automatically applies prefetch_related('posts')
GET /odata/authors/?$expand=posts($select=id,title)
# This request automatically applies select_related('author')
GET /odata/posts/?$expand=author($select=name,bio)Optimization Rules:
- Forward relationships (ForeignKey, OneToOne): Uses
select_related()for efficient JOINs - Reverse relationships (reverse ForeignKey, ManyToMany): Uses
prefetch_related()for separate optimized queries - No manual optimization needed: The package detects relationship types and applies the appropriate optimization automatically
# Get total count along with results
GET /odata/posts/?$count=true
# Get count of filtered results
GET /odata/posts/?$filter=status eq 'published'&$count=true# Get service metadata
GET /odata/posts/$metadata
# Get service document
GET /odata/from django_odata.viewsets import ODataModelViewSet
class CustomBlogPostViewSet(ODataModelViewSet):
queryset = BlogPost.objects.all()
serializer_class = BlogPostSerializer
def get_queryset(self):
\"\"\"Add custom filtering logic.\"\"\"
queryset = super().get_queryset()
# Only show published posts to non-staff users
if not self.request.user.is_staff:
queryset = queryset.filter(status='published')
return querysetfrom django_odata.serializers import create_odata_serializer
from django_odata.viewsets import create_odata_viewset
# Create serializer automatically
BlogPostSerializer = create_odata_serializer(
BlogPost,
fields=['id', 'title', 'content', 'status'],
expandable_fields={
'author': ('myapp.serializers.AuthorSerializer', {}),
}
)
# Create viewset automatically
BlogPostViewSet = create_odata_viewset(BlogPost, serializer_class=BlogPostSerializer)from django_odata.utils import ODataQueryBuilder
# Build queries programmatically
query = (ODataQueryBuilder()
.filter("status eq 'published'")
.filter("view_count gt 100")
.order('created_at', desc=True)
.limit(20)
.select('id', 'title', 'author')
.expand('author')
.build())
# query now contains the query parameters dictionary| Option | Description | Example |
|---|---|---|
$filter |
Filter results based on conditions | $filter=status eq 'published' |
$orderby |
Sort results | $orderby=created_at desc |
$top |
Limit number of results | $top=10 |
$skip |
Skip number of results | $skip=20 |
$select |
Choose specific fields | $select=id,title,status |
$expand |
Include related data | $expand=author,categories or $expand=author($select=name,bio) |
$count |
Include total count | $count=true |
| Operator | Description | Example |
|---|---|---|
eq |
Equal | status eq 'published' |
ne |
Not equal | status ne 'draft' |
gt |
Greater than | view_count gt 100 |
ge |
Greater than or equal | rating ge 4.0 |
lt |
Less than | view_count lt 50 |
le |
Less than or equal | rating le 3.0 |
and |
Logical AND | status eq 'published' and featured eq true |
or |
Logical OR | status eq 'published' or status eq 'featured' |
not |
Logical NOT | not (status eq 'draft') |
| Function | Description | Example |
|---|---|---|
contains |
String contains | contains(title,'django') |
startswith |
String starts with | startswith(title,'How to') |
endswith |
String ends with | endswith(title,'Guide') |
length |
String length | length(title) gt 10 |
tolower |
Convert to lowercase | tolower(title) eq 'django guide' |
toupper |
Convert to uppercase | toupper(status) eq 'PUBLISHED' |
| Function | Description | Example |
|---|---|---|
year |
Extract year | year(created_at) eq 2024 |
month |
Extract month | month(created_at) eq 12 |
day |
Extract day | day(created_at) eq 25 |
hour |
Extract hour | hour(created_at) eq 14 |
minute |
Extract minute | minute(created_at) eq 30 |
second |
Extract second | second(created_at) eq 45 |
Add optional settings to your Django settings:
# Optional django-odata settings
DJANGO_ODATA = {
'SERVICE_ROOT': '/odata/',
'MAX_PAGE_SIZE': 1000,
'DEFAULT_PAGE_SIZE': 50,
'ENABLE_METADATA': True,
'ENABLE_SERVICE_DOCUMENT': True,
}{
"@odata.context": "http://example.com/odata/$metadata#posts",
"@odata.count": 150,
"value": [
{
"id": 1,
"title": "Introduction to Django",
"status": "published",
"author": {
"id": 1,
"name": "John Doe",
"email": "john@example.com"
}
}
]
}{
"@odata.context": "http://example.com/odata/$metadata#posts/$entity",
"id": 1,
"title": "Introduction to Django",
"content": "This is a comprehensive guide...",
"status": "published",
"created_at": "2024-01-15T10:30:00Z"
}{
"error": {
"code": "BadRequest",
"message": "The query specified in the URI is not valid."
}
}Run the test suite:
# Install test dependencies
pip install -e .[dev]
# Run tests
pytest
# Run tests with coverage
pytest --cov=django_odataSee the example/ directory for a complete Django project demonstrating all features:
cd example/
pip install -r requirements.txt
python manage.py migrate
python manage.py runserverThen visit:
- http://localhost:8000/odata/posts/ - Blog posts endpoint
- http://localhost:8000/odata/posts/$metadata - Metadata
- [http://localhost:8000/odata/posts/?$filter=status eq 'published'&$expand=author](http://localhost:8000/odata/posts/?$filter=status eq 'published'&$expand=author) - All published posts expanded with author
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
This project is licensed under the MIT License - see the LICENSE file for details.
- Built on top of Django REST Framework
- Uses drf-flex-fields for dynamic field selection
- Uses odata-query for OData query parsing
- Initial release
- Full OData query support ($filter, $orderby, $top, $skip, $select, $expand, $count)
- Dynamic field selection and expansion
- Metadata endpoints ($metadata, service document)
- Comprehensive test suite
- Example application
- Support for Django 4.2 LTS and Python 3.8+
- Lazy import implemented