1kalin/afrexai-django-production/SKILL.md
# Django Production Engineering Complete methodology for building, scaling, and operating production Django applications. From project structure to deployment, security to performance — every decision framework a Django team needs. ## Quick Health Check Run this 8-signal triage on any Django project: | # | Signal | Check | Healthy | |---|--------|-------|---------| | 1 | Settings split | `settings/base.py`, `local.py`, `production.py` exist | ✅ Split by env | | 2 | Secret management | `SECRE
npx skillsauth add openclaw/skills 1kalin/afrexai-django-productionInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Complete methodology for building, scaling, and operating production Django applications. From project structure to deployment, security to performance — every decision framework a Django team needs.
Run this 8-signal triage on any Django project:
| # | Signal | Check | Healthy |
|---|--------|-------|---------|
| 1 | Settings split | settings/base.py, local.py, production.py exist | ✅ Split by env |
| 2 | Secret management | SECRET_KEY not in code, DEBUG=False in prod | ✅ Env vars / vault |
| 3 | Database | Using connection pooling (pgbouncer / django-db-conn-pool) | ✅ Pool configured |
| 4 | Migrations | python manage.py showmigrations — no unapplied | ✅ All applied |
| 5 | Static files | collectstatic + CDN/whitenoise configured | ✅ Served properly |
| 6 | Async tasks | Celery/django-q/Huey for background work | ✅ Not blocking views |
| 7 | Caching | Cache backend configured (Redis/Memcached) | ✅ Not DummyCache |
| 8 | Security | python manage.py check --deploy passes | ✅ All checks pass |
Score: count ✅ / 8 — Below 6 = stop and fix foundations first.
myproject/
├── config/ # Project config (was myproject/)
│ ├── __init__.py
│ ├── settings/
│ │ ├── __init__.py
│ │ ├── base.py # Shared settings
│ │ ├── local.py # Development
│ │ ├── staging.py # Staging
│ │ └── production.py # Production
│ ├── urls.py # Root URL conf
│ ├── wsgi.py
│ ├── asgi.py
│ └── celery.py # Celery app
├── apps/
│ ├── users/ # Custom user model (ALWAYS)
│ │ ├── models.py
│ │ ├── managers.py
│ │ ├── admin.py
│ │ ├── serializers.py
│ │ ├── views.py
│ │ ├── urls.py
│ │ ├── services.py # Business logic
│ │ ├── selectors.py # Complex queries
│ │ ├── tests/
│ │ │ ├── test_models.py
│ │ │ ├── test_views.py
│ │ │ └── test_services.py
│ │ └── migrations/
│ ├── core/ # Shared utilities
│ │ ├── models.py # Abstract base models
│ │ ├── permissions.py
│ │ ├── pagination.py
│ │ ├── exceptions.py
│ │ └── middleware.py
│ └── <domain>/ # Feature apps
├── templates/
├── static/
├── media/
├── requirements/
│ ├── base.txt
│ ├── local.txt
│ └── production.txt
├── docker/
├── scripts/
├── manage.py
├── pyproject.toml
├── Makefile
└── .env.example
AUTH_USER_MODEL = 'users.User'. Changing later is extremely painful.services.py handles business logic; selectors.py handles complex queries.if DEBUG conditionally in a single file.TimeStampedModel, UUIDModel shared across apps.base.txt (shared), local.txt (dev tools), production.txt (gunicorn, sentry).# apps/core/models.py
import uuid
from django.db import models
class TimeStampedModel(models.Model):
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
class UUIDModel(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
class Meta:
abstract = True
class SoftDeleteModel(models.Model):
is_deleted = models.BooleanField(default=False, db_index=True)
deleted_at = models.DateTimeField(null=True, blank=True)
class Meta:
abstract = True
def soft_delete(self):
from django.utils import timezone
self.is_deleted = True
self.deleted_at = timezone.now()
self.save(update_fields=["is_deleted", "deleted_at"])
# ❌ N+1 — fires 1 + N queries
for order in Order.objects.all():
print(order.customer.name) # Each access = new query
for item in order.items.all(): # Each access = new query
print(item.product.name) # Each access = new query
# ✅ Optimized — fires 3 queries total
orders = (
Order.objects
.select_related("customer") # FK/OneToOne — JOIN
.prefetch_related(
Prefetch(
"items",
queryset=OrderItem.objects
.select_related("product") # Nested FK
.only("id", "quantity", "product__name") # Only needed fields
)
)
)
| Relationship | Use | Why |
|---|---|---|
| ForeignKey (forward) | select_related | SQL JOIN, single query |
| OneToOneField | select_related | SQL JOIN, single query |
| ManyToMany | prefetch_related | Separate query, Python join |
| Reverse FK (set) | prefetch_related | Separate query, Python join |
| Filtered prefetch | Prefetch() object | Custom queryset |
# QuerySets are LAZY — no database hit until evaluated
qs = Order.objects.filter(status="pending") # No query yet
# These EVALUATE the queryset (trigger SQL):
list(qs) # Iteration
len(qs) # Use qs.count() instead
bool(qs) # Use qs.exists() instead
qs[0] # Indexing
repr(qs) # In shell/debugger
for obj in qs: # Iteration
if qs: # Use qs.exists()
# ❌ N queries
for item in items:
Product.objects.create(name=item["name"], price=item["price"])
# ✅ 1 query
Product.objects.bulk_create(
[Product(name=i["name"], price=i["price"]) for i in items],
batch_size=1000,
ignore_conflicts=True, # Skip duplicates
)
# ✅ Bulk update
Product.objects.filter(category="sale").update(
price=F("price") * 0.9 # 10% discount — single query, no race conditions
)
# ✅ Bulk update with different values
products = Product.objects.filter(id__in=ids)
for p in products:
p.price = new_prices[p.id]
Product.objects.bulk_update(products, ["price"], batch_size=1000)
from django.db.models import F, Q, Value, Count, Avg, Sum, Case, When
from django.db.models.functions import Coalesce, Lower, TruncMonth
# Conditional aggregation
Order.objects.aggregate(
total_revenue=Sum("amount"),
paid_revenue=Sum("amount", filter=Q(status="paid")),
refund_count=Count("id", filter=Q(status="refunded")),
avg_order_value=Avg("amount", filter=Q(status="paid")),
)
# Annotate with computed fields
customers = (
Customer.objects
.annotate(
order_count=Count("orders"),
total_spent=Coalesce(Sum("orders__amount"), Value(0)),
last_order=Max("orders__created_at"),
)
.filter(order_count__gte=5)
.order_by("-total_spent")
)
# Monthly revenue report
monthly = (
Order.objects
.filter(status="paid")
.annotate(month=TruncMonth("created_at"))
.values("month")
.annotate(
revenue=Sum("amount"),
count=Count("id"),
avg=Avg("amount"),
)
.order_by("month")
)
# Case/When for computed status
users = User.objects.annotate(
tier=Case(
When(total_spent__gte=10000, then=Value("platinum")),
When(total_spent__gte=5000, then=Value("gold")),
When(total_spent__gte=1000, then=Value("silver")),
default=Value("bronze"),
)
)
class Order(TimeStampedModel):
customer = models.ForeignKey(Customer, on_delete=models.CASCADE)
status = models.CharField(max_length=20, db_index=True) # Single column
amount = models.DecimalField(max_digits=10, decimal_places=2)
class Meta:
indexes = [
# Composite index — for queries filtering both
models.Index(fields=["status", "created_at"], name="idx_order_status_created"),
# Partial index — only index what you query
models.Index(
fields=["customer"],
condition=Q(status="pending"),
name="idx_order_pending_customer",
),
# Covering index (Postgres) — avoid table lookup
models.Index(
fields=["status"],
include=["amount", "created_at"],
name="idx_order_status_covering",
),
]
# Default ordering impacts ALL queries — be intentional
ordering = ["-created_at"]
select_related/prefetch_related — install django-debug-toolbar and watch query count..count() + .all() together — use .exists() for boolean checks.F() expressions for atomic updates — avoids race conditions..only() / .defer() for large text/JSON fields you don't need..values() / .values_list() when you don't need model instances.iterator(chunk_size=2000) for large result sets — reduces memory.django-silk or django-debug-toolbar — never guess at performance.Exists() subqueries instead of __in with large lists.# apps/orders/serializers.py
from rest_framework import serializers
class OrderListSerializer(serializers.ModelSerializer):
"""Lightweight for list views — minimal fields."""
customer_name = serializers.CharField(source="customer.name", read_only=True)
class Meta:
model = Order
fields = ["id", "customer_name", "status", "amount", "created_at"]
read_only_fields = ["id", "created_at"]
class OrderDetailSerializer(serializers.ModelSerializer):
"""Full detail with nested items."""
items = OrderItemSerializer(many=True, read_only=True)
customer = CustomerSerializer(read_only=True)
class Meta:
model = Order
fields = "__all__"
class OrderCreateSerializer(serializers.Serializer):
"""Explicit create — don't use ModelSerializer for writes."""
customer_id = serializers.UUIDField()
items = OrderItemInputSerializer(many=True)
notes = serializers.CharField(required=False, allow_blank=True)
def validate_items(self, value):
if not value:
raise serializers.ValidationError("At least one item required.")
return value
def create(self, validated_data):
# Delegate to service layer
from apps.orders.services import create_order
return create_order(**validated_data)
# apps/orders/services.py
from django.db import transaction
from django.core.exceptions import ValidationError
def create_order(*, customer_id: str, items: list[dict], notes: str = "") -> Order:
"""
Create order with items atomically.
Raises:
ValidationError: If customer not found or insufficient stock.
"""
customer = Customer.objects.filter(id=customer_id).first()
if not customer:
raise ValidationError("Customer not found.")
with transaction.atomic():
order = Order.objects.create(customer=customer, notes=notes)
order_items = []
for item_data in items:
product = Product.objects.select_for_update().get(id=item_data["product_id"])
if product.stock < item_data["quantity"]:
raise ValidationError(f"Insufficient stock for {product.name}")
product.stock -= item_data["quantity"]
product.save(update_fields=["stock"])
order_items.append(
OrderItem(order=order, product=product, quantity=item_data["quantity"], unit_price=product.price)
)
OrderItem.objects.bulk_create(order_items)
order.amount = sum(i.unit_price * i.quantity for i in order_items)
order.save(update_fields=["amount"])
# Side effects OUTSIDE transaction
send_order_confirmation.delay(order.id)
return order
# apps/orders/views.py
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
class OrderViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
def get_queryset(self):
"""Always scope to current user. Never return all objects."""
return (
Order.objects
.filter(customer__user=self.request.user)
.select_related("customer")
.prefetch_related("items__product")
)
def get_serializer_class(self):
"""Different serializers for different actions."""
if self.action == "list":
return OrderListSerializer
if self.action in ("create",):
return OrderCreateSerializer
return OrderDetailSerializer
def perform_create(self, serializer):
serializer.save()
@action(detail=True, methods=["post"])
def cancel(self, request, pk=None):
order = self.get_object()
from apps.orders.services import cancel_order
try:
cancel_order(order=order, cancelled_by=request.user)
except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
return Response({"status": "cancelled"})
@action(detail=False, methods=["get"])
def summary(self, request):
from apps.orders.selectors import get_order_summary
data = get_order_summary(user=request.user)
return Response(data)
# apps/core/pagination.py
from rest_framework.pagination import CursorPagination
class StandardCursorPagination(CursorPagination):
"""Cursor pagination — O(1) performance regardless of offset."""
page_size = 25
page_size_query_param = "page_size"
max_page_size = 100
ordering = "-created_at"
# settings/base.py
REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "apps.core.pagination.StandardCursorPagination",
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {"anon": "100/hour", "user": "1000/hour"},
"DEFAULT_RENDERER_CLASSES": ["rest_framework.renderers.JSONRenderer"],
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
],
"EXCEPTION_HANDLER": "apps.core.exceptions.custom_exception_handler",
}
# config/settings/base.py
from datetime import timedelta
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=15),
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
"ALGORITHM": "HS256",
"AUTH_HEADER_TYPES": ("Bearer",),
}
# Custom user model
# apps/users/models.py
from django.contrib.auth.models import AbstractUser
from apps.users.managers import UserManager
class User(AbstractUser):
username = None # Remove username field
email = models.EmailField(unique=True)
USERNAME_FIELD = "email"
REQUIRED_FIELDS = []
objects = UserManager()
# config/settings/production.py
import os
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = False
ALLOWED_HOSTS = os.environ["ALLOWED_HOSTS"].split(",")
# HTTPS
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Content security
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_BROWSER_XSS_FILTER = True
X_FRAME_OPTIONS = "DENY"
# CORS (django-cors-headers)
CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ORIGINS", "").split(",")
CORS_ALLOW_CREDENTIALS = True
# CSP (django-csp)
CSP_DEFAULT_SRC = ("'self'",)
CSP_SCRIPT_SRC = ("'self'",)
CSP_STYLE_SRC = ("'self'", "'unsafe-inline'")
CSP_IMG_SRC = ("'self'", "data:", "https:")
# apps/core/permissions.py
from rest_framework.permissions import BasePermission
class IsOwner(BasePermission):
"""Object-level: only the owner can access."""
def has_object_permission(self, request, view, obj):
return obj.user == request.user
class IsAdminOrReadOnly(BasePermission):
def has_permission(self, request, view):
if request.method in ("GET", "HEAD", "OPTIONS"):
return True
return request.user.is_staff
class HasRole(BasePermission):
"""Role-based access control."""
required_role = None
def has_permission(self, request, view):
if not request.user.is_authenticated:
return False
return request.user.roles.filter(name=self.required_role).exists()
class IsManager(HasRole):
required_role = "manager"
| # | Check | How | Priority |
|---|-------|-----|----------|
| 1 | manage.py check --deploy | All warnings resolved | P0 |
| 2 | SECRET_KEY from env/vault | Not in source code | P0 |
| 3 | DEBUG = False in production | Env-specific settings | P0 |
| 4 | HTTPS enforced | SECURE_SSL_REDIRECT = True | P0 |
| 5 | CSRF protection enabled | Default — don't disable it | P0 |
| 6 | SQL injection prevented | Always use ORM, never raw SQL with f-strings | P0 |
| 7 | Input validation | Serializer validation on all inputs | P1 |
| 8 | Rate limiting | DRF throttling configured | P1 |
| 9 | Admin URL changed | Not /admin/ — use random path | P1 |
| 10 | Dependency audit | pip-audit or safety check in CI | P1 |
makemigrations can produce destructive changes.null=True → deploy → backfill → make non-null.RunPython with reverse_code — always make migrations reversible.squashmigrations app_name 0001 0050 for performance.ALTER TABLE ADD COLUMN NOT NULL DEFAULT locks the table in Postgres < 11.# Step 1: Add nullable column (safe, no lock)
# migrations/0042_add_new_field.py
class Migration(migrations.Migration):
operations = [
migrations.AddField(
model_name="order",
name="tracking_number",
field=models.CharField(max_length=100, null=True, blank=True),
),
]
# Step 2: Backfill data (separate migration)
# migrations/0043_backfill_tracking.py
def backfill_tracking(apps, schema_editor):
Order = apps.get_model("orders", "Order")
batch_size = 1000
while True:
ids = list(
Order.objects.filter(tracking_number__isnull=True)
.values_list("id", flat=True)[:batch_size]
)
if not ids:
break
Order.objects.filter(id__in=ids).update(tracking_number="LEGACY")
class Migration(migrations.Migration):
operations = [
migrations.RunPython(backfill_tracking, migrations.RunPython.noop),
]
# Step 3: Make non-null (after backfill verified)
# migrations/0044_tracking_not_null.py
# When two developers create migrations from same parent:
python manage.py makemigrations --merge # Creates merge migration
# To detect conflicts in CI:
python manage.py makemigrations --check --dry-run
# config/settings/base.py
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": os.environ.get("REDIS_URL", "redis://localhost:6379/0"),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"SERIALIZER": "django_redis.serializers.json.JSONSerializer",
},
"KEY_PREFIX": "myapp",
"TIMEOUT": 300, # 5 min default
}
}
# Session storage in Redis (faster than DB)
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
from django.core.cache import cache
from django.views.decorators.cache import cache_page
from django.utils.decorators import method_decorator
# View-level caching
@cache_page(60 * 15) # 15 minutes
def product_list(request):
...
# Manual cache with invalidation
def get_product_stats(product_id: str) -> dict:
cache_key = f"product_stats:{product_id}"
stats = cache.get(cache_key)
if stats is None:
stats = _compute_product_stats(product_id)
cache.set(cache_key, stats, timeout=600)
return stats
def invalidate_product_cache(product_id: str):
cache.delete(f"product_stats:{product_id}")
# Signal-based cache invalidation
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
@receiver([post_save, post_delete], sender=Product)
def clear_product_cache(sender, instance, **kwargs):
invalidate_product_cache(str(instance.id))
cache.delete("product_list")
# Template fragment caching
# {% load cache %}
# {% cache 600 sidebar request.user.id %}
# ... expensive template fragment ...
# {% endcache %}
| Data Type | Strategy | TTL | Invalidation |
|---|---|---|---|
| User session | Redis session backend | 2 weeks | On logout |
| API list endpoint | cache_page | 5 min | Time-based |
| Computed aggregations | Manual cache.set | 10-30 min | Signal on write |
| Per-user dashboard | Manual with user key | 5 min | On user action |
| Static config/settings | Manual, long TTL | 1 hour | On admin save |
| Full-page (anonymous) | Nginx/CDN | 1-60 min | Purge API |
# config/celery.py
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
app = Celery("myapp")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
# config/settings/base.py
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/1")
CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "redis://localhost:6379/2")
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
CELERY_TIMEZONE = "UTC"
CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT = 300 # 5 min hard limit
CELERY_TASK_SOFT_TIME_LIMIT = 240 # 4 min soft limit
CELERY_TASK_ACKS_LATE = True # Re-deliver if worker crashes
CELERY_WORKER_PREFETCH_MULTIPLIER = 1 # Fair scheduling
# apps/orders/tasks.py
from celery import shared_task
from celery.utils.log import get_task_logger
logger = get_task_logger(__name__)
@shared_task(
bind=True,
max_retries=3,
default_retry_delay=60,
autoretry_for=(ConnectionError, TimeoutError),
retry_backoff=True,
retry_backoff_max=600,
acks_late=True,
)
def send_order_confirmation(self, order_id: str):
"""Send confirmation email with exponential backoff retry."""
try:
order = Order.objects.select_related("customer__user").get(id=order_id)
send_email(
to=order.customer.user.email,
template="order_confirmation",
context={"order": order},
)
logger.info("Confirmation sent", extra={"order_id": order_id})
except Order.DoesNotExist:
logger.error("Order not found", extra={"order_id": order_id})
# Don't retry — order doesn't exist
@shared_task
def generate_daily_report():
"""Periodic task — scheduled via beat."""
from apps.reports.services import build_daily_report
report = build_daily_report()
notify_admins(report)
# Celery Beat schedule
CELERY_BEAT_SCHEDULE = {
"daily-report": {
"task": "apps.orders.tasks.generate_daily_report",
"schedule": crontab(hour=6, minute=0),
},
"cleanup-expired-sessions": {
"task": "apps.users.tasks.cleanup_sessions",
"schedule": crontab(hour=3, minute=0),
},
}
task.delay(order.id) not task.delay(order). Objects can't serialize and may be stale.time_limit to prevent zombies.autoretry_for — Declarative retry for transient errors.celery -A config flower for real-time task monitoring.| Level | Tool | Target | Speed | |---|---|---|---| | Unit | pytest | Services, utils, models | <1s each | | Integration | pytest + Django test client | Views, serializers, DB | <5s each | | E2E | Playwright/Selenium | Full user flows | <30s each | | Contract | schemathesis/dredd | API schema compliance | <10s each |
# conftest.py
import pytest
from rest_framework.test import APIClient
@pytest.fixture
def api_client():
return APIClient()
@pytest.fixture
def authenticated_client(api_client, user):
api_client.force_authenticate(user=user)
return api_client
@pytest.fixture
def user(db):
return User.objects.create_user(email="[email protected]", password="testpass123")
@pytest.fixture
def order_factory(db):
def create(**kwargs):
defaults = {"status": "pending", "amount": 100}
defaults.update(kwargs)
if "customer" not in defaults:
defaults["customer"] = CustomerFactory.create()
return Order.objects.create(**defaults)
return create
# Test service layer
class TestCreateOrder:
def test_creates_order_with_items(self, db, customer, product):
order = create_order(
customer_id=customer.id,
items=[{"product_id": product.id, "quantity": 2}],
)
assert order.amount == product.price * 2
assert order.items.count() == 1
assert product.stock == Product.objects.get(id=product.id).stock # Verify stock deducted
def test_rejects_insufficient_stock(self, db, customer, product):
product.stock = 0
product.save()
with pytest.raises(ValidationError, match="Insufficient stock"):
create_order(
customer_id=customer.id,
items=[{"product_id": product.id, "quantity": 1}],
)
# Test views
class TestOrderAPI:
def test_list_returns_only_user_orders(self, authenticated_client, user, order_factory):
my_order = order_factory(customer__user=user)
other_order = order_factory() # Different user
response = authenticated_client.get("/api/orders/")
assert response.status_code == 200
ids = [o["id"] for o in response.data["results"]]
assert str(my_order.id) in ids
assert str(other_order.id) not in ids
def test_create_order_validates_items(self, authenticated_client):
response = authenticated_client.post("/api/orders/", {"items": []}, format="json")
assert response.status_code == 400
pytest-django — not Django's TestCase. Faster, better fixtures.factory_boy or custom fixtures. Never seed test DB manually.@pytest.mark.django_db — Only tests that need DB should hit it.freezegun for time-dependent logic.responses for HTTP, unittest.mock for others.pytest --cov --cov-fail-under=80 — Enforce coverage floor.# config/gunicorn.conf.py
import multiprocessing
bind = "0.0.0.0:8000"
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "gthread" # or "uvicorn.workers.UvicornWorker" for async
threads = 4
max_requests = 1000
max_requests_jitter = 50
timeout = 30
graceful_timeout = 30
keepalive = 5
accesslog = "-"
errorlog = "-"
loglevel = "info"
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware", # Static files (before everything)
"django.contrib.sessions.middleware.SessionMiddleware",
"corsheaders.middleware.CorsMiddleware", # CORS (before CommonMiddleware)
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"apps.core.middleware.RequestIDMiddleware", # Custom: attach request ID
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
# config/settings/base.py
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"json": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"format": "%(asctime)s %(name)s %(levelname)s %(message)s",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "json",
},
},
"root": {"handlers": ["console"], "level": "INFO"},
"loggers": {
"django.db.backends": {"level": "WARNING"}, # Quiet SQL logs
"apps": {"level": "INFO", "propagate": True},
},
}
| Metric | Target | How to Measure | |---|---|---| | p50 response time | <100ms | django-silk / APM | | p99 response time | <500ms | APM (Sentry, Datadog) | | DB queries per request | <10 | django-debug-toolbar | | Memory per worker | <256MB | Gunicorn + monitoring | | Celery task latency | <5s | Flower / Prometheus |
# Multi-stage build
FROM python:3.12-slim AS builder
RUN pip install --no-cache-dir uv
WORKDIR /app
COPY requirements/production.txt .
RUN uv pip install --system --no-cache -r production.txt
FROM python:3.12-slim
RUN adduser --disabled-password --no-create-home app
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.12 /usr/local/lib/python3.12
COPY --from=builder /usr/local/bin /usr/local/bin
COPY . .
RUN python manage.py collectstatic --noinput
USER app
EXPOSE 8000
CMD ["gunicorn", "config.wsgi:application", "-c", "config/gunicorn.conf.py"]
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: test_db
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports: ["5432:5432"]
redis:
image: redis:7
ports: ["6379:6379"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -r requirements/local.txt
- run: python manage.py check --deploy
env:
DJANGO_SETTINGS_MODULE: config.settings.local
- run: python manage.py makemigrations --check --dry-run
- run: pytest --cov --cov-fail-under=80 -n auto
env:
DATABASE_URL: postgres://test:test@localhost:5432/test_db
REDIS_URL: redis://localhost:6379/0
- run: ruff check .
- run: ruff format --check .
- run: mypy apps/
P0 — Must have before deploy:
manage.py check --deploy passes with zero warningsSECRET_KEY from environment variableDEBUG = FalseALLOWED_HOSTS configuredP1 — Should have within first week:
/admin/pip-audit in CIclass ActiveManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_deleted=False)
class Order(SoftDeleteModel):
objects = ActiveManager() # Default: excludes deleted
all_objects = models.Manager() # Include deleted
# Middleware: set tenant from request
class TenantMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
tenant_id = request.headers.get("X-Tenant-ID")
if tenant_id:
request.tenant = Tenant.objects.get(id=tenant_id)
return self.get_response(request)
# Auto-filter all queries by tenant
class TenantManager(models.Manager):
def get_queryset(self):
from threading import local
_thread_local = local()
qs = super().get_queryset()
tenant = getattr(_thread_local, "tenant", None)
if tenant:
qs = qs.filter(tenant=tenant)
return qs
import hashlib, hmac
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
@csrf_exempt
@require_POST
def stripe_webhook(request):
payload = request.body
sig = request.headers.get("Stripe-Signature")
try:
event = stripe.Webhook.construct_event(payload, sig, settings.STRIPE_WEBHOOK_SECRET)
except (ValueError, stripe.error.SignatureVerificationError):
return JsonResponse({"error": "Invalid signature"}, status=400)
handlers = {
"checkout.session.completed": handle_checkout,
"invoice.paid": handle_invoice_paid,
"customer.subscription.deleted": handle_cancellation,
}
handler = handlers.get(event["type"])
if handler:
handler(event["data"]["object"])
return JsonResponse({"status": "ok"})
class Product(models.Model):
price = models.DecimalField(max_digits=10, decimal_places=2)
tax_rate = models.DecimalField(max_digits=4, decimal_places=2, default=0.20)
total_price = models.GeneratedField(
expression=F("price") * (1 + F("tax_rate")),
output_field=models.DecimalField(max_digits=10, decimal_places=2),
db_persist=True, # Stored column, not virtual
)
class ContactForm(forms.Form):
name = forms.CharField()
email = forms.EmailField()
# Template: {{ form.as_field_group }}
from django.db.models.functions import Now
class Event(models.Model):
starts_at = models.DateTimeField(db_default=Now())
| # | Mistake | Fix |
|---|---------|-----|
| 1 | Not using custom User model | Always AbstractUser from Day 1 |
| 2 | N+1 queries everywhere | select_related / prefetch_related |
| 3 | Business logic in views | Move to services.py |
| 4 | One settings file | Split: base.py, local.py, production.py |
| 5 | No migration review | Always read auto-generated migrations |
| 6 | DEBUG = True in production | Env-specific settings, never conditional |
| 7 | Synchronous email sending | Celery task for all I/O |
| 8 | No connection pooling | pgbouncer or django-db-conn-pool |
| 9 | Raw SQL with f-strings | ORM or parameterized queries only |
| 10 | No request timeouts | Gunicorn timeout + DB statement_timeout |
| Dimension | Weight | Criteria |
|---|---|---|
| Architecture | 15% | Settings split, service layer, app structure |
| ORM Usage | 15% | No N+1, bulk ops, proper indexes |
| Security | 15% | check --deploy, HTTPS, auth, CSRF |
| Testing | 15% | >80% coverage, pytest, factories |
| Performance | 10% | Caching, connection pooling, query count |
| Error Handling | 10% | Structured errors, Sentry, logging |
| Migrations | 10% | Reversible, zero-downtime, reviewed |
| Deployment | 10% | Docker, CI/CD, health checks |
90-100: Production-grade, enterprise-ready 70-89: Solid, needs minor hardening 50-69: Functional but risky at scale Below 50: Technical debt crisis — stop features, fix foundations
makemigrations.select_related and prefetch_related on every queryset with relations.if DEBUG.manage.py check --deploy in CI. Zero warnings.requirements.txt, it doesn't exist.tools
Use when the user wants to connect to, test, or use the McDonalds service at mcp.mcd.cn, including checking authentication, probing MCP endpoints, listing tools, or calling McDonalds MCP tools through a reusable local CLI.
development
Web scraping platform — Twitter/X data, Vinted marketplace, and general web scraping API
development
SlowMist AI Agent Security Review — comprehensive security framework for skills, repositories, URLs, on-chain addresses, and products (Claude Code version)
data-ai
去除中文文本中的 AI 写作痕迹,使其读起来自然。基于维基百科 AI 写作特征指南,检测 24 种 AI 模式。触发词:humanizer-cn、去除 AI 痕迹、去除 AI 写作痕迹、中文文本人性化。