Standards and conventions
Code Formatting
Ruff Extension
Use the Ruff VSCode extension for code formatting and linting.
Setup:
// .vscode/settings.json
{
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true
}
}
Type Hints
Add type hints when they are not inferred to improve code readability and catch errors early.
Use generics in viewsets and serializers
Examples
from typing import Any, Optional, TYPE_CHECKING
from django.db import models
from django.http import HttpRequest
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework import serializers
def calculate_discount(price: float, discount_rate: float) -> float:
return price * (1 - discount_rate)
class Product(models.Model):
name = models.CharField(max_length=255)
price = models.DecimalField(max_digits=10, decimal_places=2)
def get_discounted_price(self, discount: float) -> float:
return float(self.price) * (1 - discount)
if TYPE_CHECKING:
from purchases.models import Purchase
purchases: models.Manager["Purchase"]
@property
def last_purchase(self): # inferred
return self.purchases.first()
from purchases.serializers import PurchaseSerializer
class ProductSerializer(serializers.ModelSerializer[Product]):
last_purchase = PurchaseSerializer(read_only=True)
pretty_price = serializers.SerializerMethodField()
def get_pretty_price(self, obj:Product):
return format_price(obj.price)
class ProductViewSet(viewsets.ModelViewSet[Product]):
queryset = Product.objects.all()
serializer_class = ProductSerializer
Models
Choice Fields with ChoiceEnum
Always use ChoiceEnum
instead of TextChoices
or IntegerChoices
for model choices.
from django.db import models
from utils import ChoiceEnum # Custom ChoiceEnum class
class Order(models.Model):
class Status(ChoiceEnum):
PENDING = "pending", "Pending"
PROCESSING = "processing", "Processing"
COMPLETED = "completed", "Completed"
CANCELLED = "cancelled", "Cancelled"
status = Status.as_field(default=Status.PENDING)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self) -> str:
return f"Order #{self.id} - {self.status.label}"
Model Best Practices
from typing import Optional
from django.db import models
from django.contrib.auth import get_user_model
User = get_user_model()
class Product(models.Model):
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
price = models.DecimalField(max_digits=10, decimal_places=2)
is_active = models.BooleanField(default=True)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
verbose_name = "Product"
verbose_name_plural = "Products"
def __str__(self) -> str:
return self.name
Querysets and Managers
Prefer overriding querysets and managers for adding custom annotations and queries to models.
Custom QuerySet
from django.db import models
from django.db.models import Count, Sum, Q, QuerySet
from typing import Any
class ProductQuerySet(models.QuerySet):
def active(self) -> QuerySet:
"""Return only active products."""
return self.filter(is_active=True)
def with_order_count(self) -> QuerySet:
"""Annotate products with their order count."""
return self.annotate(order_count=Count("orders"))
def with_total_revenue(self) -> QuerySet:
"""Annotate products with total revenue."""
return self.annotate(
total_revenue=Sum("orders__total_amount")
)
def search(self, query: str) -> QuerySet:
"""Search products by name or description."""
return self.filter(
Q(name__icontains=query) | Q(description__icontains=query)
)
class ProductManager(models.Manager):
def get_queryset(self) -> ProductQuerySet:
return ProductQuerySet(self.model, using=self._db)
def active(self):
return self.get_queryset().active()
def with_stats(self):
return self.get_queryset().with_order_count().with_total_revenue()
class Product(models.Model):
name = models.CharField(max_length=255)
description = models.TextField()
is_active = models.BooleanField(default=True)
objects:ProductManager = ProductManager()
class Meta:
ordering = ["-created_at"]
Usage
# In views or services
active_products = Product.objects.active()
products_with_stats = Product.objects.with_stats()
search_results = Product.objects.active().search("laptop")
Views and ViewSets
Prefer ViewSets
Always prefer using ViewSets over APIView or function-based views.
from typing import Any
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend
from .models import Product
from .serializers import ProductSerializer, ProductDetailSerializer
from .filters import ProductFilter
class ProductViewSet(viewsets.ModelViewSet):
queryset = Product.objects.with_stats()
serializer_class = ProductSerializer
filter_backends = [DjangoFilterBackend]
filterset_class = ProductFilter
def get_serializer_class(self):
if self.action == "retrieve":
return ProductDetailSerializer
return ProductSerializer
def get_queryset(self):
"""Override to add custom queryset logic."""
queryset = super().get_queryset()
if self.action == "list":
queryset = queryset.active()
return queryset
Using @action Decorator
Prefer using @action
decorator instead of @api_view
for custom endpoints.
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.request import Request
from rest_framework import status
from typing import Any
class ProductViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=["post"])
def activate(self, request: Request, pk: int = None) -> Response:
"""Activate a product."""
product = self.get_object()
product.is_active = True
product.save()
return Response({"status": "product activated"})
@action(detail=True, methods=["post"])
def deactivate(self, request: Request, pk: int = None) -> Response:
"""Deactivate a product."""
product = self.get_object()
product.is_active = False
product.save()
return Response({"status": "product deactivated"})
@action(detail=False, methods=["get"])
def featured(self, request: Request) -> Response:
"""Get featured products."""
products = self.get_queryset().filter(is_featured=True)
serializer = self.get_serializer(products, many=True)
return Response(serializer.data)
Using @action.mapping
Prefer @action.mapping
to create multiple HTTP methods for the same action.
class ProductViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['PUT'], url)
def status(self, request: Request, pk: int = None) -> Response:
"""Toggle product active status."""
product = self.get_object()
product.is_active = not product.is_active
product.save()
serializer = self.get_serializer(product)
return Response(serializer.data)
@toggle_status.mapping.get
def get_status(self, request: Request, pk: int = None) -> Response:
"""Return product status"""
product = self.get_object()
return Response({"is_active": product.is_active})
@toggle_status.mapping.delete
def remove_status(self, request: Request, product: Any) -> Response:
"""Reset product status"""
product = self.get_object()
product.is_active = None
product.save()
return Response({"is_active": product.is_active})
Serializers
Using GetUserMixin
Use utils.GetUserMixin
to access the current user in serializers. maybe_get_user
returns either a Profile
or None
, while get_user
throws an exception if there's no user authenticated
Use extend_schema
to document SerializerMethodField.
from rest_framework import serializers
from utils import GetUserMixin
from .models import Product, Order
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes
class ProductSerializer(serializers.ModelSerializer[Product],GetUserMixin):
created_by_name = serializers.CharField(source="created_by.name", read_only=True)
created_by_me = serializers.SerializerMethodField()
class Meta:
model = Product
fields = ["id", "name", "description", "price", "created_by", "created_by_name"]
read_only_fields = ["created_by"]
def create(self, validated_data: dict) -> Product:
# Access current user via get_user method
user = self.get_user()
validated_data["created_by"] = user
return super().create(validated_data)
@extend_schema_field(OpenApiTypes.BOOL) # Shows up in openapi spec
def get_created_by_me(self, obj:Product):
return obj.created_by == self.maybe_get_user()
class ProductViewSet(viewsets.ModelViewSet[Product],GetUserMixin):
serializer_class = ProductSerializer
def perform_create(self, serializer):
serializer.save(created_by=self.get_user())
def get_queryset(self):
if self.action in ["update","partial_update","delete"]
return super().get_queryset().filter(created_by=self.get_user())
return super().get_queryset()
Business Logic
Services Pattern
Place business logic in app_name/services.py
and consume it in models, views, serializers, and tasks.
# products/services.py
from typing import Dict, Any, Optional
from django.db import transaction
from django.contrib.auth import get_user_model
from .models import Product, Order, Inventory
User = get_user_model()
class ProductService:
"""Service for product-related business logic."""
@staticmethod
def create_product(data: Dict[str, Any], user: User) -> Product:
"""Create a new product with inventory."""
with transaction.atomic():
product = Product.objects.create(
name=data["name"],
description=data.get("description", ""),
price=data["price"],
created_by=user,
)
# Create inventory entry
Inventory.objects.create(
product=product,
quantity=data.get("initial_quantity", 0),
)
return product
@staticmethod
def update_product_price(product: Product, new_price: float) -> Product:
"""Update product price with validation."""
if new_price <= 0:
raise ValueError("Price must be greater than 0")
product.price = new_price
product.save(update_fields=["price", "updated_at"])
return product
class OrderService:
"""Service for order-related business logic."""
@classmethod
def create_order(cls, data: Dict[str, Any]) -> Order:
"""Create order and update inventory."""
with transaction.atomic():
product = data["product"]
quantity = data["quantity"]
# Check inventory
inventory = Inventory.objects.select_for_update().get(product=product)
if inventory.quantity < quantity:
raise ValueError("Insufficient inventory")
# Create order
order = Order.objects.create(
product=product,
customer=data["customer"],
quantity=quantity,
total_amount=product.price * quantity,
)
# Update inventory
inventory.quantity -= quantity
inventory.save()
return order
@classmethod
def cancel_order(cls, order: Order) -> Order:
"""Cancel order and restore inventory."""
with transaction.atomic():
if order.status == "cancelled":
raise ValueError("Order is already cancelled")
order.status = "cancelled"
order.save()
# Restore inventory
inventory = Inventory.objects.select_for_update().get(product=order.product)
inventory.quantity += order.quantity
inventory.save()
return order
Using Services in Views
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.request import Request
from utils import GetUserMixin
from .services import OrderService
class OrderViewSet(GetUserMixin, viewsets.ModelViewSet):
service = OrderService() # It's a good idea to attach these in views so we can switch them easily
@action(detail=True, methods=["post"])
def cancel(self, request: Request, pk: int = None):
"""Cancel an order."""
order = self.get_object()
self.service.cancel_order(order)
return Response({"message": "order cancelled"})
Using Services in Celery Tasks
# products/tasks.py
from celery import shared_task
from .models import Product
from .services import ProductService
@shared_task
def update_product_prices(percentage_increase: float) -> None:
"""Update all product prices by percentage."""
products = Product.objects.active()
for product in products:
new_price = float(product.price) * (1 + percentage_increase / 100)
ProductService.update_product_price(product, new_price)
Returning errors
Use the ViewException class to return errors.
class ChannelViewset(viewsets.ModelViewset[Channel]):
service = FollowService()
@decorators.action(
methods=[HTTPMethod.POST], detail=True, url_path="follow", url_name="follow"
)
def toggle_follow(self, request, pk=None):
channel = self.get_object()
user = self.get_user()
if user.pk == channel.owner.pk:
raise ViewException( # The frontend and middleware are prepared to handle this
user_message="You cannot follow yourself",
status_code=status.HTTP_400_BAD_REQUEST,
)
now_following = self.service.toggle_following(user, channel)
return response.Response(data={"following": now_following})
Filtering
Using django-filters
Prefer django_filters.drf
for filtering in ViewSets. Always add help_text for query parameters.
# products/filters.py
from django_filters import rest_framework as filters
from django.db.models import Q
from .models import Product, OrderStatus
class ProductFilter(filters.FilterSet):
name = filters.CharFilter(
lookup_expr="icontains",
help_text="Filter by product name (case-insensitive partial match)"
)
min_price = filters.NumberFilter(
field_name="price",
lookup_expr="gte",
help_text="Filter products with price greater than or equal to this value"
)
max_price = filters.NumberFilter(
field_name="price",
lookup_expr="lte",
help_text="Filter products with price less than or equal to this value"
)
is_active = filters.BooleanFilter(
help_text="Filter by active status (true/false)"
)
created_after = filters.DateTimeFilter(
field_name="created_at",
lookup_expr="gte",
help_text="Filter products created after this date (ISO 8601 format)"
)
search = filters.CharFilter(
method="filter_search",
help_text="Search in product name and description"
)
class Meta:
model = Product
fields = ["name", "is_active", "min_price", "max_price", "created_after"]
def filter_search(self, queryset, name, value):
"""Custom search across multiple fields."""
return queryset.filter(
Q(name__icontains=value) | Q(description__icontains=value)
)
class OrderFilter(filters.FilterSet):
status = filters.ChoiceFilter(
choices=OrderStatus.choices,
help_text="Filter by order status (pending, processing, completed, cancelled)"
)
customer = filters.NumberFilter(
help_text="Filter by customer ID"
)
product = filters.NumberFilter(
help_text="Filter by product ID"
)
date_from = filters.DateFilter(
field_name="created_at",
lookup_expr="gte",
help_text="Filter orders from this date (YYYY-MM-DD)"
)
date_to = filters.DateFilter(
field_name="created_at",
lookup_expr="lte",
help_text="Filter orders up to this date (YYYY-MM-DD)"
)
min_amount = filters.NumberFilter(
field_name="total_amount",
lookup_expr="gte",
help_text="Filter orders with total amount greater than or equal to this value"
)
class Meta:
model = Order
fields = ["status", "customer", "product"]
Using Filters in ViewSets
from rest_framework import viewsets
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import OrderingFilter, SearchFilter
from .models import Product
from .serializers import ProductSerializer
from .filters import ProductFilter
class ProductViewSet(viewsets.ModelViewSet):
queryset = Product.objects.all()
serializer_class = ProductSerializer
filterset_class = ProductFilter
ordering_fields = ["price", "created_at", "name"]
ordering = ["-created_at"]
search_fields = ["name", "description"]
Example API Calls:
# Filter by name
GET /products/?name=laptop
# Filter by price range
GET /products/?min_price=100&max_price=500
# Search
GET /products/?search=wireless
# Combine filters
GET /products/?is_active=true&min_price=50&ordering=-price
# Filter by date range
GET /orders/?date_from=2024-01-01&date_to=2024-12-31
Project Structure
Recommended App Structure
app_name/
├── __init__.py
├── admin.py
├── apps.py
├── filters.py # Django-filters FilterSet classes
├── managers.py # Custom managers and querysets
├── migrations/
├── models.py # Database models
├── serializers.py # DRF serializers
├── services.py # Business logic
├── tasks.py # Celery tasks
├── tests/
│ ├── __init__.py
│ ├── test_models.py
│ ├── test_serializers.py
│ ├── test_services.py
│ └── test_views.py
├── urls.py # URL routing
└── views.py # ViewSets and views
You can break this into subapps if necessary
app_name/
├── migrations/ # This is django managed
├── apps.py # This is django managed
apps/
subapp/
├── __init__.py
├── admin.py
├── filters.py # Django-filters FilterSet classes
├── managers.py # Custom managers and querysets
├── models.py # Database models
├── serializers.py # DRF serializers
├── services.py # Business logic
├── tasks.py # Celery tasks
├── tests/
│ ├── __init__.py
│ ├── test_models.py
│ ├── test_serializers.py
│ ├── test_services.py
│ └── test_views.py
├── urls.py # URL routing
└── views.py # ViewSets and views
another_subapp/
├── __init__.py
├── admin.py
├── apps.py
├── filters.py # Django-filters FilterSet classes
├── managers.py # Custom managers and querysets
├── models.py # Database models
├── serializers.py # DRF serializers
├── services.py # Business logic
├── tasks.py # Celery tasks
├── tests/
│ ├── __init__.py
│ ├── test_models.py
│ ├── test_serializers.py
│ ├── test_services.py
│ └── test_views.py
├── urls.py # URL routing
└── views.py # ViewSets and views
Additional Best Practices
Extra: Use Select/Prefetch Related
class OrderViewSet(viewsets.ModelViewSet):
def get_queryset(self):
return Order.objects.select_related(
"customer", "product"
).prefetch_related(
"items"
)
Extra: Permissions
from rest_framework import permissions
class IsOwnerOrReadOnly(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
return obj.created_by == request.user
class ProductViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticated, IsOwnerOrReadOnly]
Extra: API Documentation
from drf_spectacular.utils import extend_schema, OpenApiParameter
from rest_framework.decorators import action
class ProductViewSet(viewsets.ModelViewSet):
@extend_schema(
summary="Activate a product",
description="Sets the product's is_active flag to True",
responses={200: ProductSerializer}
)
@action(detail=True, methods=["post"])
def activate(self, request, pk=None):
product = self.get_object()
product.is_active = True
product.save()
serializer = self.get_serializer(product)
return Response(serializer.data)