mirror of
https://github.com/duthaho/claudekit.git
synced 2026-06-15 14:34:55 +03:00
713 lines
23 KiB
Markdown
713 lines
23 KiB
Markdown
# Backend Frameworks — Django Patterns
|
|
|
|
|
|
# Django
|
|
|
|
## When to Use
|
|
|
|
- Python web applications
|
|
- Admin interfaces
|
|
- Django REST Framework APIs
|
|
- Content-heavy sites with ORM-driven data models
|
|
|
|
## When NOT to Use
|
|
|
|
- FastAPI projects — use the `fastapi` skill instead for async APIs and microservices
|
|
- JavaScript/Node.js backends (Express, NestJS) — this skill is Python-only
|
|
- Microservices architectures — consider FastAPI instead for lightweight, async services
|
|
|
|
---
|
|
|
|
## Core Patterns
|
|
|
|
### 1. Models & ORM
|
|
|
|
#### Field types and relationships
|
|
|
|
```python
|
|
from django.db import models
|
|
from django.utils import timezone
|
|
|
|
class Organization(models.Model):
|
|
name = models.CharField(max_length=200)
|
|
slug = models.SlugField(unique=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
ordering = ["name"]
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
class User(models.Model):
|
|
class Role(models.TextChoices):
|
|
ADMIN = "admin", "Administrator"
|
|
MEMBER = "member", "Member"
|
|
VIEWER = "viewer", "Viewer"
|
|
|
|
email = models.EmailField(unique=True)
|
|
name = models.CharField(max_length=100)
|
|
organization = models.ForeignKey(
|
|
Organization,
|
|
on_delete=models.CASCADE,
|
|
related_name="members",
|
|
)
|
|
role = models.CharField(max_length=20, choices=Role.choices, default=Role.MEMBER)
|
|
is_active = models.BooleanField(default=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
ordering = ["-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["email"]),
|
|
models.Index(fields=["organization", "role"]),
|
|
]
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=["organization", "email"],
|
|
name="unique_org_email",
|
|
),
|
|
]
|
|
|
|
def __str__(self):
|
|
return self.email
|
|
|
|
class Tag(models.Model):
|
|
name = models.CharField(max_length=50, unique=True)
|
|
|
|
class Project(models.Model):
|
|
title = models.CharField(max_length=200)
|
|
description = models.TextField(blank=True)
|
|
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name="owned_projects")
|
|
organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
|
|
tags = models.ManyToManyField(Tag, blank=True, related_name="projects")
|
|
# OneToOneField for 1:1 relationships
|
|
settings = models.OneToOneField(
|
|
"ProjectSettings", on_delete=models.CASCADE, null=True, blank=True
|
|
)
|
|
|
|
class ProjectSettings(models.Model):
|
|
is_public = models.BooleanField(default=False)
|
|
max_members = models.IntegerField(default=10)
|
|
```
|
|
|
|
#### Custom managers and QuerySet methods
|
|
|
|
```python
|
|
class ActiveManager(models.Manager):
|
|
def get_queryset(self):
|
|
return super().get_queryset().filter(is_active=True)
|
|
|
|
class UserQuerySet(models.QuerySet):
|
|
def admins(self):
|
|
return self.filter(role=User.Role.ADMIN)
|
|
|
|
def in_organization(self, org_id):
|
|
return self.filter(organization_id=org_id)
|
|
|
|
def with_project_count(self):
|
|
return self.annotate(project_count=models.Count("owned_projects"))
|
|
|
|
class User(models.Model):
|
|
# ... fields ...
|
|
objects = UserQuerySet.as_manager()
|
|
active = ActiveManager()
|
|
```
|
|
|
|
#### F objects, Q objects, and annotations
|
|
|
|
```python
|
|
from django.db.models import F, Q, Count, Avg, Sum, Value, When, Case
|
|
|
|
# F objects: reference model fields in queries
|
|
Project.objects.filter(updated_at__gt=F("created_at"))
|
|
User.objects.update(login_count=F("login_count") + 1) # Atomic increment
|
|
|
|
# Q objects: complex lookups with OR, AND, NOT
|
|
User.objects.filter(
|
|
Q(role="admin") | Q(role="member"),
|
|
~Q(is_active=False), # NOT inactive
|
|
)
|
|
|
|
# Annotations and aggregations
|
|
orgs = Organization.objects.annotate(
|
|
member_count=Count("members"),
|
|
admin_count=Count("members", filter=Q(members__role="admin")),
|
|
avg_projects=Avg("members__owned_projects"),
|
|
).filter(member_count__gte=5)
|
|
|
|
# Conditional expressions
|
|
users = User.objects.annotate(
|
|
tier=Case(
|
|
When(owned_projects__count__gte=10, then=Value("power")),
|
|
When(owned_projects__count__gte=3, then=Value("active")),
|
|
default=Value("starter"),
|
|
)
|
|
)
|
|
|
|
# Subqueries
|
|
from django.db.models import Subquery, OuterRef
|
|
latest_project = Project.objects.filter(
|
|
owner=OuterRef("pk")
|
|
).order_by("-created_at").values("title")[:1]
|
|
users = User.objects.annotate(latest_project_title=Subquery(latest_project))
|
|
```
|
|
|
|
### 2. Views
|
|
|
|
#### Function-based views
|
|
|
|
```python
|
|
from django.shortcuts import render, get_object_or_404, redirect
|
|
from django.http import JsonResponse
|
|
from django.contrib.auth.decorators import login_required
|
|
|
|
@login_required
|
|
def project_detail(request, project_id):
|
|
project = get_object_or_404(
|
|
Project.objects.select_related("owner", "organization"),
|
|
pk=project_id,
|
|
)
|
|
if request.method == "POST":
|
|
form = ProjectForm(request.POST, instance=project)
|
|
if form.is_valid():
|
|
form.save()
|
|
return redirect("project-detail", project_id=project.id)
|
|
else:
|
|
form = ProjectForm(instance=project)
|
|
|
|
return render(request, "projects/detail.html", {
|
|
"project": project,
|
|
"form": form,
|
|
})
|
|
```
|
|
|
|
#### Class-based views
|
|
|
|
```python
|
|
from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView
|
|
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
|
|
from django.urls import reverse_lazy
|
|
|
|
class ProjectListView(LoginRequiredMixin, ListView):
|
|
model = Project
|
|
template_name = "projects/list.html"
|
|
context_object_name = "projects"
|
|
paginate_by = 20
|
|
|
|
def get_queryset(self):
|
|
qs = super().get_queryset().select_related("owner", "organization")
|
|
search = self.request.GET.get("q")
|
|
if search:
|
|
qs = qs.filter(
|
|
Q(title__icontains=search) | Q(description__icontains=search)
|
|
)
|
|
return qs
|
|
|
|
class ProjectCreateView(LoginRequiredMixin, CreateView):
|
|
model = Project
|
|
form_class = ProjectForm
|
|
template_name = "projects/form.html"
|
|
success_url = reverse_lazy("project-list")
|
|
|
|
def form_valid(self, form):
|
|
form.instance.owner = self.request.user
|
|
form.instance.organization = self.request.user.organization
|
|
return super().form_valid(form)
|
|
|
|
class ProjectDeleteView(PermissionRequiredMixin, DeleteView):
|
|
model = Project
|
|
permission_required = "projects.delete_project"
|
|
success_url = reverse_lazy("project-list")
|
|
```
|
|
|
|
#### Mixins for reuse
|
|
|
|
```python
|
|
class OrganizationFilterMixin:
|
|
"""Filter queryset to the current user's organization."""
|
|
def get_queryset(self):
|
|
return super().get_queryset().filter(
|
|
organization=self.request.user.organization
|
|
)
|
|
|
|
class ProjectListView(LoginRequiredMixin, OrganizationFilterMixin, ListView):
|
|
model = Project
|
|
# queryset is automatically filtered by organization
|
|
```
|
|
|
|
#### API views with Django REST Framework
|
|
|
|
```python
|
|
from rest_framework.decorators import api_view, permission_classes
|
|
from rest_framework.permissions import IsAuthenticated
|
|
from rest_framework.response import Response
|
|
from rest_framework import status
|
|
|
|
@api_view(["GET", "POST"])
|
|
@permission_classes([IsAuthenticated])
|
|
def project_list(request):
|
|
if request.method == "GET":
|
|
projects = Project.objects.filter(organization=request.user.organization)
|
|
serializer = ProjectSerializer(projects, many=True)
|
|
return Response(serializer.data)
|
|
|
|
serializer = ProjectCreateSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
serializer.save(owner=request.user)
|
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
|
```
|
|
|
|
### 3. Migrations
|
|
|
|
#### Creating and running migrations
|
|
|
|
```bash
|
|
# Generate migrations after model changes
|
|
python manage.py makemigrations app_name
|
|
|
|
# Preview SQL without applying
|
|
python manage.py sqlmigrate app_name 0001
|
|
|
|
# Apply migrations
|
|
python manage.py migrate
|
|
|
|
# Show migration status
|
|
python manage.py showmigrations
|
|
```
|
|
|
|
#### Data migrations with RunPython
|
|
|
|
```python
|
|
from django.db import migrations
|
|
|
|
def populate_slugs(apps, schema_editor):
|
|
Organization = apps.get_model("myapp", "Organization")
|
|
from django.utils.text import slugify
|
|
for org in Organization.objects.filter(slug=""):
|
|
org.slug = slugify(org.name)
|
|
org.save(update_fields=["slug"])
|
|
|
|
def reverse_populate_slugs(apps, schema_editor):
|
|
pass # No-op reverse
|
|
|
|
class Migration(migrations.Migration):
|
|
dependencies = [
|
|
("myapp", "0005_add_slug_field"),
|
|
]
|
|
operations = [
|
|
migrations.RunPython(populate_slugs, reverse_populate_slugs),
|
|
]
|
|
```
|
|
|
|
#### Squashing migrations
|
|
|
|
```bash
|
|
# Squash migrations 0001 through 0010 into one
|
|
python manage.py squashmigrations app_name 0001 0010
|
|
```
|
|
|
|
**Tips:**
|
|
- Always provide a reverse function for `RunPython` (even if it is a no-op)
|
|
- Use `apps.get_model()` in data migrations, never import models directly
|
|
- Test migrations on a copy of production data before deploying
|
|
|
|
### 4. Forms
|
|
|
|
#### ModelForm with custom validation
|
|
|
|
```python
|
|
from django import forms
|
|
from django.core.exceptions import ValidationError
|
|
|
|
class ProjectForm(forms.ModelForm):
|
|
class Meta:
|
|
model = Project
|
|
fields = ["title", "description", "tags"]
|
|
widgets = {
|
|
"description": forms.Textarea(attrs={"rows": 4}),
|
|
"tags": forms.CheckboxSelectMultiple(),
|
|
}
|
|
|
|
def clean_title(self):
|
|
title = self.cleaned_data["title"]
|
|
if "test" in title.lower() and not self.instance.pk:
|
|
raise ValidationError("Title cannot contain 'test' for new projects.")
|
|
return title
|
|
|
|
def clean(self):
|
|
cleaned = super().clean()
|
|
title = cleaned.get("title", "")
|
|
description = cleaned.get("description", "")
|
|
if len(title) + len(description) < 20:
|
|
raise ValidationError("Title + description must be at least 20 characters.")
|
|
return cleaned
|
|
```
|
|
|
|
#### Formsets
|
|
|
|
```python
|
|
from django.forms import inlineformset_factory
|
|
|
|
TaskFormSet = inlineformset_factory(
|
|
Project,
|
|
Task,
|
|
fields=["title", "assigned_to", "due_date"],
|
|
extra=2, # Number of empty forms
|
|
can_delete=True,
|
|
max_num=20,
|
|
)
|
|
|
|
# In a view
|
|
def project_tasks(request, project_id):
|
|
project = get_object_or_404(Project, pk=project_id)
|
|
if request.method == "POST":
|
|
formset = TaskFormSet(request.POST, instance=project)
|
|
if formset.is_valid():
|
|
formset.save()
|
|
return redirect("project-detail", project_id=project.id)
|
|
else:
|
|
formset = TaskFormSet(instance=project)
|
|
return render(request, "projects/tasks.html", {"formset": formset})
|
|
```
|
|
|
|
### 5. Signals
|
|
|
|
```python
|
|
from django.db.models.signals import post_save, pre_save, m2m_changed
|
|
from django.dispatch import receiver
|
|
|
|
@receiver(post_save, sender=User)
|
|
def create_user_profile(sender, instance, created, **kwargs):
|
|
if created:
|
|
UserProfile.objects.create(user=instance)
|
|
|
|
@receiver(pre_save, sender=Project)
|
|
def set_project_slug(sender, instance, **kwargs):
|
|
if not instance.slug:
|
|
from django.utils.text import slugify
|
|
instance.slug = slugify(instance.title)
|
|
|
|
# Custom signals
|
|
from django.dispatch import Signal
|
|
|
|
project_published = Signal() # Accepts sender
|
|
|
|
@receiver(project_published)
|
|
def notify_members(sender, project, **kwargs):
|
|
for member in project.organization.members.all():
|
|
send_notification(member, f"Project '{project.title}' published")
|
|
|
|
# Firing a custom signal
|
|
project_published.send(sender=Project, project=project)
|
|
```
|
|
|
|
**When to use signals vs overriding `save()`:**
|
|
- Use signals when the action is a side effect (notifications, logging, cache invalidation)
|
|
- Override `save()` when the logic is core to the model's behavior (setting computed fields)
|
|
|
|
### 6. Middleware
|
|
|
|
```python
|
|
import time
|
|
from django.utils.deprecation import MiddlewareMixin
|
|
|
|
class TimingMiddleware(MiddlewareMixin):
|
|
def process_request(self, request):
|
|
request._start_time = time.perf_counter()
|
|
|
|
def process_response(self, request, response):
|
|
if hasattr(request, "_start_time"):
|
|
duration = time.perf_counter() - request._start_time
|
|
response["X-Process-Time"] = f"{duration:.4f}"
|
|
return response
|
|
|
|
# New-style middleware (function-based)
|
|
def organization_middleware(get_response):
|
|
def middleware(request):
|
|
if request.user.is_authenticated:
|
|
request.organization = request.user.organization
|
|
else:
|
|
request.organization = None
|
|
response = get_response(request)
|
|
return response
|
|
return middleware
|
|
|
|
# Register in settings.py
|
|
MIDDLEWARE = [
|
|
"django.middleware.security.SecurityMiddleware",
|
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
"django.middleware.common.CommonMiddleware",
|
|
"django.middleware.csrf.CsrfViewMiddleware",
|
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
"myapp.middleware.organization_middleware", # Custom
|
|
"myapp.middleware.TimingMiddleware", # Custom
|
|
"django.contrib.messages.middleware.MessageMiddleware",
|
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
|
]
|
|
```
|
|
|
|
### 7. Django REST Framework
|
|
|
|
#### Serializers
|
|
|
|
```python
|
|
from rest_framework import serializers
|
|
|
|
class UserSerializer(serializers.ModelSerializer):
|
|
project_count = serializers.IntegerField(read_only=True)
|
|
full_name = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = User
|
|
fields = ["id", "email", "name", "role", "full_name", "project_count", "created_at"]
|
|
read_only_fields = ["id", "created_at"]
|
|
|
|
def get_full_name(self, obj):
|
|
return f"{obj.name} ({obj.role})"
|
|
|
|
class ProjectSerializer(serializers.ModelSerializer):
|
|
owner = UserSerializer(read_only=True)
|
|
tags = serializers.SlugRelatedField(
|
|
many=True, slug_field="name", queryset=Tag.objects.all()
|
|
)
|
|
|
|
class Meta:
|
|
model = Project
|
|
fields = ["id", "title", "description", "owner", "tags", "created_at"]
|
|
|
|
def validate_title(self, value):
|
|
if len(value) < 3:
|
|
raise serializers.ValidationError("Title must be at least 3 characters.")
|
|
return value
|
|
|
|
class ProjectCreateSerializer(serializers.ModelSerializer):
|
|
class Meta:
|
|
model = Project
|
|
fields = ["title", "description", "tags"]
|
|
```
|
|
|
|
#### ViewSets and routers
|
|
|
|
```python
|
|
from rest_framework import viewsets, permissions, filters
|
|
from rest_framework.decorators import action
|
|
from rest_framework.response import Response
|
|
from django_filters.rest_framework import DjangoFilterBackend
|
|
|
|
class ProjectViewSet(viewsets.ModelViewSet):
|
|
serializer_class = ProjectSerializer
|
|
permission_classes = [permissions.IsAuthenticated]
|
|
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
|
filterset_fields = ["owner", "tags"]
|
|
search_fields = ["title", "description"]
|
|
ordering_fields = ["created_at", "title"]
|
|
ordering = ["-created_at"]
|
|
|
|
def get_queryset(self):
|
|
return Project.objects.filter(
|
|
organization=self.request.user.organization
|
|
).select_related("owner").prefetch_related("tags")
|
|
|
|
def get_serializer_class(self):
|
|
if self.action == "create":
|
|
return ProjectCreateSerializer
|
|
return ProjectSerializer
|
|
|
|
def perform_create(self, serializer):
|
|
serializer.save(
|
|
owner=self.request.user,
|
|
organization=self.request.user.organization,
|
|
)
|
|
|
|
@action(detail=True, methods=["post"])
|
|
def publish(self, request, pk=None):
|
|
project = self.get_object()
|
|
project.is_published = True
|
|
project.save(update_fields=["is_published"])
|
|
return Response({"status": "published"})
|
|
|
|
# urls.py
|
|
from rest_framework.routers import DefaultRouter
|
|
|
|
router = DefaultRouter()
|
|
router.register("projects", ProjectViewSet, basename="project")
|
|
router.register("users", UserViewSet, basename="user")
|
|
|
|
urlpatterns = [
|
|
path("api/", include(router.urls)),
|
|
]
|
|
```
|
|
|
|
#### Permissions
|
|
|
|
```python
|
|
from rest_framework.permissions import BasePermission
|
|
|
|
class IsOrganizationAdmin(BasePermission):
|
|
def has_permission(self, request, view):
|
|
return (
|
|
request.user.is_authenticated
|
|
and request.user.role == User.Role.ADMIN
|
|
)
|
|
|
|
class IsOwnerOrReadOnly(BasePermission):
|
|
def has_object_permission(self, request, view, obj):
|
|
if request.method in permissions.SAFE_METHODS:
|
|
return True
|
|
return obj.owner == request.user
|
|
```
|
|
|
|
#### Pagination
|
|
|
|
```python
|
|
from rest_framework.pagination import PageNumberPagination, CursorPagination
|
|
|
|
class StandardPagination(PageNumberPagination):
|
|
page_size = 20
|
|
page_size_query_param = "page_size"
|
|
max_page_size = 100
|
|
|
|
class TimelinePagination(CursorPagination):
|
|
page_size = 50
|
|
ordering = "-created_at"
|
|
|
|
# settings.py
|
|
REST_FRAMEWORK = {
|
|
"DEFAULT_PAGINATION_CLASS": "myapp.pagination.StandardPagination",
|
|
"PAGE_SIZE": 20,
|
|
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
|
|
"DEFAULT_AUTHENTICATION_CLASSES": [
|
|
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
|
],
|
|
}
|
|
```
|
|
|
|
### 8. Admin
|
|
|
|
```python
|
|
from django.contrib import admin
|
|
from django.utils.html import format_html
|
|
|
|
class TaskInline(admin.TabularInline):
|
|
model = Task
|
|
extra = 0
|
|
fields = ["title", "assigned_to", "status", "due_date"]
|
|
readonly_fields = ["created_at"]
|
|
|
|
@admin.register(Project)
|
|
class ProjectAdmin(admin.ModelAdmin):
|
|
list_display = ["title", "owner_name", "organization", "tag_list", "created_at"]
|
|
list_filter = ["organization", "tags", "created_at"]
|
|
search_fields = ["title", "description", "owner__email"]
|
|
readonly_fields = ["created_at", "updated_at"]
|
|
autocomplete_fields = ["owner", "organization"]
|
|
prepopulated_fields = {"slug": ("title",)}
|
|
date_hierarchy = "created_at"
|
|
inlines = [TaskInline]
|
|
|
|
fieldsets = (
|
|
(None, {
|
|
"fields": ("title", "slug", "description"),
|
|
}),
|
|
("Ownership", {
|
|
"fields": ("owner", "organization", "tags"),
|
|
}),
|
|
("Metadata", {
|
|
"classes": ("collapse",),
|
|
"fields": ("created_at", "updated_at"),
|
|
}),
|
|
)
|
|
|
|
def owner_name(self, obj):
|
|
return obj.owner.name
|
|
owner_name.short_description = "Owner"
|
|
owner_name.admin_order_field = "owner__name"
|
|
|
|
def tag_list(self, obj):
|
|
return ", ".join(t.name for t in obj.tags.all())
|
|
tag_list.short_description = "Tags"
|
|
|
|
def get_queryset(self, request):
|
|
return super().get_queryset(request).select_related(
|
|
"owner", "organization"
|
|
).prefetch_related("tags")
|
|
|
|
# Custom admin actions
|
|
@admin.action(description="Mark selected projects as published")
|
|
def make_published(self, request, queryset):
|
|
count = queryset.update(is_published=True)
|
|
self.message_user(request, f"{count} projects published.")
|
|
|
|
actions = [make_published]
|
|
|
|
@admin.register(User)
|
|
class UserAdmin(admin.ModelAdmin):
|
|
list_display = ["email", "name", "organization", "role", "is_active"]
|
|
list_filter = ["role", "is_active", "organization"]
|
|
search_fields = ["email", "name"]
|
|
list_editable = ["role", "is_active"]
|
|
list_per_page = 50
|
|
```
|
|
|
|
---
|
|
|
|
## Best Practices
|
|
|
|
1. **Use `select_related` and `prefetch_related` on every query that touches relations** — `select_related` for ForeignKey/OneToOne (SQL JOIN), `prefetch_related` for ManyToMany and reverse ForeignKey (separate query). Check queries with `django-debug-toolbar`.
|
|
|
|
2. **Keep business logic in model methods or service functions, not in views** — views should handle HTTP, forms should handle validation, models/services should handle domain logic. This makes code testable without needing HTTP.
|
|
|
|
3. **Use `get_queryset()` for dynamic filtering instead of hardcoding querysets** — both in views and DRF ViewSets. This enables mixin composition and per-request filtering (e.g., by organization).
|
|
|
|
4. **Write data migrations for schema changes that require backfills** — never assume fields can be added as non-nullable without a migration to populate existing rows. Use `RunPython` with a reverse function.
|
|
|
|
5. **Configure Django REST Framework defaults in settings** — set `DEFAULT_PAGINATION_CLASS`, `DEFAULT_PERMISSION_CLASSES`, `DEFAULT_AUTHENTICATION_CLASSES` in `REST_FRAMEWORK` dict to avoid repeating yourself on each ViewSet.
|
|
|
|
6. **Use `TextChoices` / `IntegerChoices` for enum fields** — they integrate with admin filters, serializer validation, and migrations automatically. Avoid plain strings or integers for status/role fields.
|
|
|
|
7. **Index frequently queried fields** — add `db_index=True` on individual fields or use `Meta.indexes` for composite indexes. Add `UniqueConstraint` for business-rule uniqueness.
|
|
|
|
8. **Use Django's `transaction.atomic()` for multi-step writes** — wrap create/update sequences that must succeed or fail together. DRF's `perform_create` and `perform_update` are good places for this.
|
|
|
|
```python
|
|
from django.db import transaction
|
|
|
|
@transaction.atomic
|
|
def transfer_project(project, new_owner):
|
|
old_owner = project.owner
|
|
project.owner = new_owner
|
|
project.save(update_fields=["owner"])
|
|
AuditLog.objects.create(
|
|
action="transfer",
|
|
project=project,
|
|
from_user=old_owner,
|
|
to_user=new_owner,
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## Common Pitfalls
|
|
|
|
1. **N+1 queries** — accessing `project.owner.name` in a loop without `select_related("owner")` fires one query per iteration. Use `django-debug-toolbar` or `nplusone` to detect these. Always optimize queryset in `get_queryset()`.
|
|
|
|
2. **Importing models directly in data migrations** — models change over time, but migrations are frozen. Always use `apps.get_model("app_name", "ModelName")` inside `RunPython` functions, never `from myapp.models import Model`.
|
|
|
|
3. **Forgetting to call `full_clean()` in model saves** — Django's `save()` does NOT run validators by default. Only forms and serializers call `full_clean()`. If you save models directly, add explicit validation.
|
|
|
|
4. **Circular imports between apps** — referencing models across apps can cause import cycles. Use string references in ForeignKey: `models.ForeignKey("other_app.ModelName", ...)` instead of importing the class.
|
|
|
|
5. **Overusing signals for core logic** — signals make code harder to trace and debug. Use them for side effects (sending emails, cache invalidation), not for core domain logic. If logic should always run on save, override `save()` instead.
|
|
|
|
6. **Returning entire QuerySets from service functions** — QuerySets are lazy, which is usually good, but returning them from service layers can lead to unexpected queries executing in templates. Use `.values()`, `.values_list()`, or serialize to dicts when crossing layer boundaries.
|
|
|
|
---
|
|
|
|
## Related Skills
|
|
|
|
- `python` — Python language patterns and best practices
|
|
- `postgresql` — Database integration and query optimization
|
|
- `pytest` — Testing Django applications with pytest-django
|