feat(auth): add Casdoor SSO integration via django-allauth
Some checks failed
CVE Scan & Docker Build / security-scan (push) Successful in 50s
CVE Scan & Docker Build / build-and-push (push) Has been cancelled

Integrate OIDC-based SSO authentication through Casdoor using
django-allauth. Adds configuration for enabling SSO, custom account
adapters, and an optional SSL verification bypass for sandbox
environments with self-signed certificates.

- Add CASDOOR_* and ALLOW_LOCAL_LOGIN env vars to .env.example and
  docker-compose (app service only)
- Configure allauth with openid_connect provider for Casdoor
- Register custom adapters (CasdoorAccountAdapter, LocalAccountAdapter)
- Apply SSL patch early in settings when CASDOOR_SSL_VERIFY=false
This commit is contained in:
2026-05-12 11:53:22 -04:00
parent 955761b748
commit ed4d0db930
13 changed files with 1246 additions and 329 deletions

View File

@@ -0,0 +1,739 @@
# SSO with Allauth & Casdoor Pattern v1.0.0
Standardizes OIDC-based Single Sign-On using Django Allauth and Casdoor, covering adapter customization, user provisioning, group mapping, superuser protection, and configurable local-login fallback. Used by the `core` Django application.
## 🐾 Red Panda Approval™
This pattern follows Red Panda Approval standards.
---
## Why a Pattern, Not a Shared Implementation
Every Django project that adopts SSO has different identity-provider configurations, claim schemas, permission models, and organizational structures:
- A **project management** app needs role claims mapped to project-scoped permissions
- An **e-commerce** app needs tenant/store claims with purchase-limit groups
- An **RFP tool** (Spelunker) needs organization + group claims mapped to View Only / Staff / SME / Admin groups
Instead, this pattern defines:
- **Required components** — every implementation must have
- **Required settings** — Django & Allauth configuration values
- **Standard conventions** — group names, claim mappings, redirect URL format
- **Extension guidelines** — for domain-specific provisioning logic
---
## Required Components
Every SSO implementation following this pattern must provide these files:
| Component | Location | Purpose |
|-----------|----------|---------|
| Social account adapter | `<app>/adapters.py` | User provisioning, group mapping, superuser protection |
| Local account adapter | `<app>/adapters.py` | Disable local signup, authentication logging |
| Management command | `<app>/management/commands/create_sso_groups.py` | Idempotent group + permission creation |
| Login template | `templates/account/login.html` | SSO button + conditional local login form |
| Context processor | `<app>/context_processors.py` | Expose `CASDOOR_ENABLED` / `ALLOW_LOCAL_LOGIN` to templates |
| SSL patch (optional) | `<app>/ssl_patch.py` | Development-only SSL bypass |
### Minimum settings.py configuration
```python
# INSTALLED_APPS — required entries
INSTALLED_APPS = [
# ... standard Django apps ...
'allauth',
'allauth.account',
'allauth.socialaccount',
'allauth.socialaccount.providers.openid_connect',
'<your_app>',
]
# MIDDLEWARE — Allauth middleware is required
MIDDLEWARE = [
# ... standard Django middleware ...
'allauth.account.middleware.AccountMiddleware',
]
# AUTHENTICATION_BACKENDS — both local and SSO
AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.ModelBackend',
'allauth.account.auth_backends.AuthenticationBackend',
]
```
---
## Standard Values / Conventions
### Environment Variables
Every deployment must set these environment variables (or `.env` entries):
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `CASDOOR_ENABLED` | Yes | — | Enable/disable SSO (`true`/`false`) |
| `CASDOOR_ORIGIN` | Yes | — | Casdoor backend URL for OIDC discovery |
| `CASDOOR_ORIGIN_FRONTEND` | Yes | — | Casdoor frontend URL (may differ behind reverse proxy) |
| `CASDOOR_CLIENT_ID` | Yes | — | OAuth client ID from Casdoor application |
| `CASDOOR_CLIENT_SECRET` | Yes | — | OAuth client secret from Casdoor application |
| `CASDOOR_ORG_NAME` | Yes | — | Default organization slug in Casdoor |
| `ALLOW_LOCAL_LOGIN` | No | `false` | Show local login form for non-superusers |
| `CASDOOR_SSL_VERIFY` | No | `true` | SSL verification (`true`, `false`, or CA-bundle path) |
### Redirect URL Convention
The Allauth OIDC callback URL follows a fixed format. Register this URL in Casdoor:
```
/accounts/oidc/<provider_id>/login/callback/
```
For Spelunker with `provider_id = casdoor`:
```
/accounts/oidc/casdoor/login/callback/
```
> **Important:** The path segment is `oidc`, not `openid_connect`.
### Standard Group Mapping
Casdoor group names map to Django groups with consistent naming:
| Casdoor Group | Django Group | `is_staff` | Permissions |
|---------------|-------------|------------|-------------|
| `view_only` | `View Only` | `False` | `view_*` |
| `staff` | `Staff` | `True` | `view_*`, `add_*`, `change_*` |
| `sme` | `SME` | `True` | `view_*`, `add_*`, `change_*` |
| `admin` | `Admin` | `True` | `view_*`, `add_*`, `change_*`, `delete_*` |
### Standard OIDC Claim Mapping
| Casdoor Claim | Django Field | Notes |
|---------------|-------------|-------|
| `email` | `User.username`, `User.email` | Full email used as username |
| `given_name` | `User.first_name` | — |
| `family_name` | `User.last_name` | — |
| `name` | Parsed into first/last | Fallback when given/family absent |
| `organization` | Organization lookup/create | Via adapter |
| `groups` | Django Group membership | Via adapter mapping |
---
## Recommended Settings
Most implementations should include these Allauth settings:
```python
# Authentication mode
ACCOUNT_LOGIN_METHODS = {'email'}
ACCOUNT_SIGNUP_FIELDS = ['email*', 'password1*', 'password2*']
ACCOUNT_EMAIL_VERIFICATION = 'optional'
ACCOUNT_SESSION_REMEMBER = True
ACCOUNT_LOGIN_ON_PASSWORD_RESET = True
ACCOUNT_UNIQUE_EMAIL = True
# Redirects
LOGIN_REDIRECT_URL = '/dashboard/'
ACCOUNT_LOGOUT_REDIRECT_URL = '/'
LOGIN_URL = '/accounts/login/'
# Social account behavior
SOCIALACCOUNT_AUTO_SIGNUP = True
SOCIALACCOUNT_EMAIL_VERIFICATION = 'none'
SOCIALACCOUNT_QUERY_EMAIL = True
SOCIALACCOUNT_STORE_TOKENS = True
SOCIALACCOUNT_ADAPTER = '<app>.adapters.CasdoorAccountAdapter'
ACCOUNT_ADAPTER = '<app>.adapters.LocalAccountAdapter'
# Session management
SESSION_COOKIE_AGE = 28800 # 8 hours
SESSION_SAVE_EVERY_REQUEST = True
# Account linking — auto-connect SSO to an existing local account with
# the same verified email instead of raising a conflict error
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True
```
### Multi-Factor Authentication (Recommended)
Add `allauth.mfa` for TOTP/WebAuthn second-factor support:
```python
INSTALLED_APPS += ['allauth.mfa']
MFA_ADAPTER = 'allauth.mfa.adapter.DefaultMFAAdapter'
```
MFA is enforced per-user inside Django; Casdoor may also enforce its own MFA upstream.
### Rate Limiting on Local Login (Recommended)
Protect the local login form from brute-force attacks with `django-axes` or similar:
```python
# pip install django-axes
INSTALLED_APPS += ['axes']
AUTHENTICATION_BACKENDS = [
'axes.backends.AxesStandaloneBackend',
'django.contrib.auth.backends.ModelBackend',
'allauth.account.auth_backends.AuthenticationBackend',
]
AXES_FAILURE_LIMIT = 5 # Lock after 5 failures
AXES_COOLOFF_TIME = 1 # 1-hour cooloff
AXES_LOCKOUT_PARAMETERS = ['ip_address', 'username']
```
---
## Social Account Adapter
The social account adapter is the core of the pattern. It handles user provisioning on SSO login, maps claims to Django fields, enforces superuser protection, and assigns groups.
```python
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth.exceptions import ImmediateHttpResponse
from django.contrib.auth.models import User, Group
from django.contrib import messages
from django.shortcuts import redirect
import logging
logger = logging.getLogger(__name__)
class CasdoorAccountAdapter(DefaultSocialAccountAdapter):
def is_open_for_signup(self, request, sociallogin):
"""Always allow SSO-initiated signup."""
return True
def pre_social_login(self, request, sociallogin):
"""
Runs on every SSO login (new and returning users).
1. Blocks superusers — they must use local auth.
2. Re-syncs organization and group claims for returning users
so that IdP changes are reflected immediately.
"""
if sociallogin.user.id:
user = sociallogin.user
# --- Superuser gate ---
if user.is_superuser:
logger.warning(
f"SSO login blocked for superuser {user.username}. "
"Superusers must use local authentication."
)
messages.error(
request,
"Superuser accounts must use local authentication."
)
raise ImmediateHttpResponse(redirect('account_login'))
# --- Re-sync claims for returning users ---
extra_data = sociallogin.account.extra_data
org_identifier = extra_data.get('organization', '')
if org_identifier:
self._assign_organization(user, org_identifier)
groups = extra_data.get('groups', [])
self._assign_groups(user, groups)
user.is_staff = any(
g in ['staff', 'sme', 'admin'] for g in groups
)
user.save(update_fields=['is_staff'])
def populate_user(self, request, sociallogin, data):
"""Map Casdoor claims to Django User fields."""
user = super().populate_user(request, sociallogin, data)
email = data.get('email', '')
user.username = email
user.email = email
user.first_name = data.get('given_name', '')
user.last_name = data.get('family_name', '')
# Fallback: parse full 'name' claim
if not user.first_name and not user.last_name:
full_name = data.get('name', '')
if full_name:
parts = full_name.split(' ', 1)
user.first_name = parts[0]
user.last_name = parts[1] if len(parts) > 1 else ''
# Security: SSO users are never superusers
user.is_superuser = False
# Set is_staff from group membership
groups = data.get('groups', [])
user.is_staff = any(g in ['staff', 'sme', 'admin'] for g in groups)
return user
def save_user(self, request, sociallogin, form=None):
"""Save user and handle organization + group mapping."""
user = super().save_user(request, sociallogin, form)
extra_data = sociallogin.account.extra_data
org_identifier = extra_data.get('organization', '')
if org_identifier:
self._assign_organization(user, org_identifier)
groups = extra_data.get('groups', [])
self._assign_groups(user, groups)
return user
def _assign_organization(self, user, org_identifier):
"""Assign (or create) organization from the OIDC claim."""
# Domain-specific — see Extension Examples below
raise NotImplementedError("Override per project")
def _assign_groups(self, user, group_names):
"""Map Casdoor groups to Django groups."""
group_mapping = {
'view_only': 'View Only',
'staff': 'Staff',
'sme': 'SME',
'admin': 'Admin',
}
user.groups.clear()
for casdoor_group in group_names:
django_group_name = group_mapping.get(casdoor_group.lower())
if django_group_name:
group, _ = Group.objects.get_or_create(name=django_group_name)
user.groups.add(group)
logger.info(f"Added {user.username} to group {django_group_name}")
```
---
## Local Account Adapter
Prevents local registration and logs authentication failures:
```python
from allauth.account.adapter import DefaultAccountAdapter
import logging
logger = logging.getLogger(__name__)
class LocalAccountAdapter(DefaultAccountAdapter):
def is_open_for_signup(self, request):
"""Disable local signup — all users come via SSO or admin."""
return False
def authentication_failed(self, request, **kwargs):
"""Log failures for security monitoring."""
logger.warning(
f"Local authentication failed from {request.META.get('REMOTE_ADDR')}"
)
super().authentication_failed(request, **kwargs)
```
---
## OIDC Provider Configuration
Register Casdoor as an OpenID Connect provider in `settings.py`:
```python
SOCIALACCOUNT_PROVIDERS = {
'openid_connect': {
'APPS': [
{
'provider_id': 'casdoor',
'name': 'Casdoor SSO',
'client_id': CASDOOR_CLIENT_ID,
'secret': CASDOOR_CLIENT_SECRET,
'settings': {
'server_url': f'{CASDOOR_ORIGIN}/.well-known/openid-configuration',
},
}
],
'OAUTH_PKCE_ENABLED': True,
}
}
```
---
## Management Command — Group Creation
An idempotent management command ensures groups and permissions exist:
```python
from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group, Permission
class Command(BaseCommand):
help = 'Create Django groups for Casdoor SSO integration'
def handle(self, *args, **options):
groups_config = {
'View Only': {'permissions': ['view']},
'Staff': {'permissions': ['view', 'add', 'change']},
'SME': {'permissions': ['view', 'add', 'change']},
'Admin': {'permissions': ['view', 'add', 'change', 'delete']},
}
# Add your domain-specific model names here
models_to_permission = [
'vendor', 'document', 'rfp', 'rfpquestion',
]
for group_name, config in groups_config.items():
group, created = Group.objects.get_or_create(name=group_name)
status = 'Created' if created else 'Exists'
self.stdout.write(f'{status}: {group_name}')
for perm_prefix in config['permissions']:
for model in models_to_permission:
try:
perm = Permission.objects.get(
codename=f'{perm_prefix}_{model}'
)
group.permissions.add(perm)
except Permission.DoesNotExist:
pass
self.stdout.write(self.style.SUCCESS('SSO groups created successfully'))
```
---
## Login Template
The login template shows an SSO button when Casdoor is enabled and conditionally reveals the local login form:
```html
{% load socialaccount %}
<!-- SSO Login Button (POST form for CSRF protection) -->
{% if CASDOOR_ENABLED %}
<form method="post" action="{% provider_login_url 'casdoor' %}">
{% csrf_token %}
<button type="submit">Sign in with SSO</button>
</form>
{% endif %}
<!-- Local Login Form (conditional) -->
{% if ALLOW_LOCAL_LOGIN or user.is_superuser %}
<form method="post" action="{% url 'account_login' %}">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Sign In Locally</button>
</form>
{% endif %}
```
> **Why POST?** Using a `<a href>` GET link to initiate the OAuth flow skips CSRF
> validation. Allauth's `{% provider_login_url %}` is designed for use inside a
> `<form method="post">` so the CSRF token is verified before the redirect.
---
## Context Processor
Exposes SSO settings to every template:
```python
from django.conf import settings
def user_preferences(request):
context = {}
# Always expose SSO flags for the login page
context['CASDOOR_ENABLED'] = getattr(settings, 'CASDOOR_ENABLED', False)
context['ALLOW_LOCAL_LOGIN'] = getattr(settings, 'ALLOW_LOCAL_LOGIN', False)
return context
```
Register in `settings.py`:
```python
TEMPLATES = [{
'OPTIONS': {
'context_processors': [
# ... standard processors ...
'<app>.context_processors.user_preferences',
],
},
}]
```
---
## SSL Bypass (Development Only)
For sandbox environments with self-signed certificates, an optional SSL patch disables verification at the `requests` library level:
```python
import os, logging
logger = logging.getLogger(__name__)
def apply_ssl_bypass():
ssl_verify = os.environ.get('CASDOOR_SSL_VERIFY', 'true').lower()
if ssl_verify != 'false':
return
logger.warning("SSL verification DISABLED — sandbox only")
import urllib3
from requests.adapters import HTTPAdapter
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
_original_send = HTTPAdapter.send
def _patched_send(self, request, stream=False, timeout=None,
verify=True, cert=None, proxies=None):
return _original_send(self, request, stream=stream,
timeout=timeout, verify=False,
cert=cert, proxies=proxies)
HTTPAdapter.send = _patched_send
apply_ssl_bypass()
```
Load it at the top of `settings.py` **before** any library imports that make HTTP calls:
```python
_ssl_verify = os.environ.get('CASDOOR_SSL_VERIFY', 'true').lower()
if _ssl_verify == 'false':
import <app>.ssl_patch # noqa: F401
```
---
## Logout Flow
By default, Django's `account_logout` destroys the local session but does **not** terminate the upstream Casdoor session. The user remains logged in at the IdP and will be silently re-authenticated on next visit.
### Options
| Strategy | Behaviour | Implementation |
|----------|-----------|----------------|
| **Local-only logout** (default) | Destroys Django session; IdP session survives | No extra work |
| **IdP redirect logout** | Redirects to Casdoor's `/api/logout` after local logout | Override `ACCOUNT_LOGOUT_REDIRECT_URL` to point at Casdoor |
| **OIDC back-channel logout** | Casdoor notifies Django to invalidate sessions | Requires Casdoor back-channel support + a Django webhook endpoint |
### Recommended: IdP redirect logout
```python
# settings.py
ACCOUNT_LOGOUT_REDIRECT_URL = (
f'{CASDOOR_ORIGIN}/api/logout'
f'?post_logout_redirect_uri=https://your-app.example.com/'
)
```
This ensures the Casdoor session cookie is cleared before the user returns to your app.
---
## Domain Extension Examples
### Spelunker (RFP Tool)
Spelunker's adapter creates organizations on first encounter and links them to user profiles:
```python
def _assign_organization(self, user, org_identifier):
from django.db import models
from django.utils.text import slugify
from core.models import Organization
try:
org = Organization.objects.filter(
models.Q(slug=org_identifier) | models.Q(name=org_identifier)
).first()
if not org:
org = Organization.objects.create(
name=org_identifier,
slug=slugify(org_identifier),
type='for-profit',
legal_country='CA',
status='active',
)
logger.info(f"Created organization: {org.name}")
if hasattr(user, 'profile'):
logger.info(f"Assigned {user.username}{org.name}")
except Exception as e:
logger.error(f"Organization assignment error: {e}")
```
### Multi-Tenant SaaS App
A multi-tenant app might restrict users to a single tenant and enforce tenant isolation:
```python
def _assign_organization(self, user, org_identifier):
from tenants.models import Tenant
tenant = Tenant.objects.filter(external_id=org_identifier).first()
if not tenant:
raise ValueError(f"Unknown tenant: {org_identifier}")
user.tenant = tenant
user.save(update_fields=['tenant'])
```
---
## Anti-Patterns
- ❌ Don't allow SSO to grant `is_superuser` — always force `is_superuser = False` in `populate_user`
- ❌ Don't *log-and-continue* for superuser SSO attempts — raise `ImmediateHttpResponse` to actually block the login
- ❌ Don't disable local login for superusers — they need emergency access when SSO is unavailable
- ❌ Don't rely on SSO username claims — use email as the canonical identifier
- ❌ Don't hard-code the OIDC provider URL — always read from environment variables
- ❌ Don't skip the management command — groups and permissions must be idempotent and repeatable
- ❌ Don't use `CASDOOR_SSL_VERIFY=false` in production — only for sandbox environments with self-signed certificates
- ❌ Don't forget PKCE — always set `OAUTH_PKCE_ENABLED: True` for Authorization Code flow
- ❌ Don't sync groups only on first login — re-sync in `pre_social_login` so IdP changes take effect immediately
- ❌ Don't use a GET link (`<a href>`) to start the OAuth flow — use a POST form so CSRF protection applies
- ❌ Don't assume Django logout kills the IdP session — configure an IdP redirect or back-channel logout
- ❌ Don't leave the local login endpoint unprotected — add rate limiting (e.g. `django-axes`) to prevent brute-force attacks
---
## Settings
All Django settings this pattern recognizes:
```python
# settings.py
# --- SSO Provider ---
CASDOOR_ENABLED = env.bool('CASDOOR_ENABLED') # Master SSO toggle
CASDOOR_ORIGIN = env('CASDOOR_ORIGIN') # OIDC discovery base URL
CASDOOR_ORIGIN_FRONTEND = env('CASDOOR_ORIGIN_FRONTEND') # Frontend URL (may differ)
CASDOOR_CLIENT_ID = env('CASDOOR_CLIENT_ID') # OAuth client ID
CASDOOR_CLIENT_SECRET = env('CASDOOR_CLIENT_SECRET') # OAuth client secret
CASDOOR_ORG_NAME = env('CASDOOR_ORG_NAME') # Default organization
# CASDOOR_SSL_VERIFY is NOT a Django setting — it is read directly from the
# environment at the top of settings.py (before any imports) to apply SSL
# bypass via ssl_patch.py or set REQUESTS_CA_BUNDLE for a custom CA.
# See the SSL Bypass section above for the correct implementation.
# --- Login Behavior ---
ALLOW_LOCAL_LOGIN = env.bool('ALLOW_LOCAL_LOGIN', default=False) # Show local form
# --- Allauth ---
SOCIALACCOUNT_ADAPTER = '<app>.adapters.CasdoorAccountAdapter'
ACCOUNT_ADAPTER = '<app>.adapters.LocalAccountAdapter'
```
---
## Testing
Standard test cases every implementation should cover:
```python
from django.test import TestCase, override_settings
from unittest.mock import MagicMock
from django.contrib.auth.models import User, Group
from <app>.adapters import CasdoorAccountAdapter, LocalAccountAdapter
class CasdoorAdapterTest(TestCase):
def setUp(self):
self.adapter = CasdoorAccountAdapter()
def test_signup_always_open(self):
"""SSO signup must always be permitted."""
self.assertTrue(self.adapter.is_open_for_signup(MagicMock(), MagicMock()))
def test_superuser_never_set_via_sso(self):
"""populate_user must force is_superuser=False."""
sociallogin = MagicMock()
data = {'email': 'admin@example.com', 'groups': ['admin']}
user = self.adapter.populate_user(MagicMock(), sociallogin, data)
self.assertFalse(user.is_superuser)
def test_email_used_as_username(self):
"""Username must be the full email address."""
sociallogin = MagicMock()
data = {'email': 'jane@example.com'}
user = self.adapter.populate_user(MagicMock(), sociallogin, data)
self.assertEqual(user.username, 'jane@example.com')
def test_staff_flag_from_groups(self):
"""is_staff must be True when user belongs to staff/sme/admin."""
sociallogin = MagicMock()
for group in ['staff', 'sme', 'admin']:
data = {'email': 'user@example.com', 'groups': [group]}
user = self.adapter.populate_user(MagicMock(), sociallogin, data)
self.assertTrue(user.is_staff, f"is_staff should be True for group '{group}'")
def test_name_fallback_parsing(self):
"""When given_name/family_name absent, parse 'name' claim."""
sociallogin = MagicMock()
data = {'email': 'user@example.com', 'name': 'Jane Doe'}
user = self.adapter.populate_user(MagicMock(), sociallogin, data)
self.assertEqual(user.first_name, 'Jane')
self.assertEqual(user.last_name, 'Doe')
def test_group_mapping(self):
"""Casdoor groups must map to correctly named Django groups."""
Group.objects.create(name='View Only')
Group.objects.create(name='Staff')
user = User.objects.create_user('test@example.com', 'test@example.com')
self.adapter._assign_groups(user, ['view_only', 'staff'])
group_names = set(user.groups.values_list('name', flat=True))
self.assertEqual(group_names, {'View Only', 'Staff'})
def test_superuser_sso_login_blocked(self):
"""pre_social_login must raise ImmediateHttpResponse for superusers."""
from allauth.exceptions import ImmediateHttpResponse
user = User.objects.create_superuser(
'admin@example.com', 'admin@example.com', 'pass'
)
sociallogin = MagicMock()
sociallogin.user = user
sociallogin.user.id = user.id
with self.assertRaises(ImmediateHttpResponse):
self.adapter.pre_social_login(MagicMock(), sociallogin)
def test_groups_resync_on_returning_login(self):
"""pre_social_login must re-sync groups for existing users."""
Group.objects.create(name='Admin')
Group.objects.create(name='Staff')
user = User.objects.create_user('user@example.com', 'user@example.com')
user.groups.add(Group.objects.get(name='Staff'))
sociallogin = MagicMock()
sociallogin.user = user
sociallogin.user.id = user.id
sociallogin.account.extra_data = {
'groups': ['admin'],
'organization': '',
}
self.adapter.pre_social_login(MagicMock(), sociallogin)
group_names = set(user.groups.values_list('name', flat=True))
self.assertEqual(group_names, {'Admin'})
class LocalAdapterTest(TestCase):
def test_local_signup_disabled(self):
"""Local signup must always be disabled."""
adapter = LocalAccountAdapter()
self.assertFalse(adapter.is_open_for_signup(MagicMock()))
```

View File

@@ -1,324 +0,0 @@
## 🐾 Red Panda Approval™
This project follows Red Panda Approval standards — our gold standard for Django application quality. Code must be elegant, reliable, and maintainable to earn the approval of our adorable red panda judges.
### The 5 Sacred Django Criteria
1. **Fresh Migration Test** — Clean migrations from empty database
2. **Elegant Simplicity** — No unnecessary complexity
3. **Observable & Debuggable** — Proper logging and error handling
4. **Consistent Patterns** — Follow Django conventions
5. **Actually Works** — Passes all checks and serves real user needs
## Environment Standards
- Virtual environment: ~/env/PROJECT/bin/activate
- Use pyproject.toml for project configuration (no setup.py, no requirements.txt)
- Python version: specified in pyproject.toml
- Dependencies: floor-pinned with ceiling (e.g. `Django>=5.2,<6.0`)
### Dependency Pinning
```toml
# Correct — floor pin with ceiling
dependencies = [
"Django>=5.2,<6.0",
"djangorestframework>=3.14,<4.0",
"cryptography>=41.0,<45.0",
]
# Wrong — exact pins in library packages
dependencies = [
"Django==5.2.7", # too strict, breaks downstream
]
```
Exact pins (`==`) are only appropriate in application-level lock files, not in reusable library packages.
## Directory Structure
myproject/ # Git repository root
├── .gitignore
├── README.md
├── pyproject.toml # Project configuration (moved to repo root)
├── docker-compose.yml
├── .env # Docker Compose environment
│ # DB_ENGINE=postgresql
│ # APP_DB_NAME=angelia2
│ # APP_DB_USER=angelia
│ # APP_DB_PASSWORD=changeme
│ # DB_HOST=db
│ # DB_PORT=5432
├── .env.example
├── project/ # Django project root (manage.py lives here)
│ ├── manage.py
│ ├── Dockerfile
│ ├── .env # Local development environment
│ │ # DB_ENGINE=sqlite
├── .env.example
├── config/ # Django configuration module
│ │ ├── __init__.py
│ │ ├── settings.py
│ │ ├── urls.py
│ │ ├── wsgi.py
│ │ └── asgi.py
│ │
│ ├── accounts/ # Django app
│ │ ├── __init__.py
│ │ ├── models.py
│ │ ├── views.py
│ │ └── urls.py
│ │
│ ├── blog/ # Django app
│ │ ├── __init__.py
│ │ ├── models.py
│ │ ├── views.py
│ │ └── urls.py
│ │
│ ├── static/
│ │ ├── css/
│ │ └── js/
│ │
│ └── templates/
│ └── base.html
├── web/ # Nginx configuration
│ └── nginx.conf
├── db/ # PostgreSQL configuration
│ └── postgresql.conf
└── docs/ # Project documentation
└── index.md
## Settings Structure
- Use a single settings.py file
- Use django-environ or python-dotenv for environment variables
- Never commit .env files to version control
- Provide .env.example with all required variables documented
- Create .gitignore file
- Create a .dockerignore file
## Environment Variables
### PostgreSQL settings (only if DB_ENGINE=postgresql)
```
APP_DB_NAME=angelia2
APP_DB_USER=angelia
APP_DB_PASSWORD=changeme
DB_HOST=db
DB_PORT=5432
```
## Code Organization
- Imports: PEP 8 ordering (stdlib, third-party, local)
- Type hints on function parameters
- CSS: External .css files only (no inline styles, no embedded `<style>` tags)
- JS: External .js files only (no inline handlers, no embedded `<script>` blocks)
- Maximum file length: 1000 lines
- If a file exceeds 500 lines, consider splitting by domain concept
## Database Conventions
- Migrations run cleanly from empty database
- Never edit deployed migrations
- Use meaningful migration names: --name add_email_to_profile
- One logical change per migration when possible
- Test migrations both forward and backward
### Development vs Production
- Development: SQLite
- Production: PostgreSQL
## Caching
- Expensive queries are cached
- Cache keys follow naming convention
- TTLs are appropriate (not infinite)
- Invalidation is documented
- Key Naming Pattern: {app}:{model}:{identifier}:{field}
## Model Naming
- Model names: singular PascalCase (User, BlogPost, OrderItem)
- Correct English pluralization on related names
- All models have created_at and updated_at
- All models define __str__ and get_absolute_url
- TextChoices used for status fields
- related_name defined on ForeignKey fields
- Related names: plural snake_case with proper English pluralization
## Forms
- Use ModelForm with explicit fields list (never __all__)
## Field Naming
- Foreign keys: singular without _id suffix (author, category, parent)
- Boolean fields: use prefixes (is_active, has_permission, can_edit)
- Date fields: use suffixes (created_at, updated_at, published_on)
- Avoid abbreviations (use description, not desc)
## Required Model Fields
- All models should include:
- created_at = models.DateTimeField(auto_now_add=True)
- updated_at = models.DateTimeField(auto_now=True)
- Consider adding:
- id = models.UUIDField(primary_key=True) for public-facing models
- is_active = models.BooleanField(default=True) for soft deletes
## Indexing
- Add db_index=True to frequently queried fields
- Use Meta.indexes for composite indexes
- Document why each index exists
## Queries
- Use select_related() for foreign keys
- Use prefetch_related() for reverse relations and M2M
- Avoid queries in loops (N+1 problem)
- Use .only() and .defer() for large models
- Add comments explaining complex querysets
## Docstrings
- Use Sphinx style docstrings
- Document all public functions, classes, and modules
- Skip docstrings for obvious one-liners and standard Django overrides
## Views
- Use Function-Based Views (FBVs) exclusively
- Explicit logic is preferred over implicit inheritance
- Extract shared logic into utility functions
## URLs & Identifiers
- Public URLs use short UUIDs (12 characters) via `shortuuid`
- Never expose sequential IDs in URLs (security/enumeration risk)
- Internal references may use standard UUIDs or PKs
## URL Patterns
- Resource-based URLs (RESTful style)
- Namespaced URL names per app
- Trailing slashes (Django default)
- Flat structure preferred over deep nesting
## Background Tasks
- All tasks are run synchronously unless the design specifies background tasks are needed for long operations
- Long operations use Celery tasks
- Use Memcached, task progress pattern: {app}:task:{task_id}:progress
- Tasks are idempotent
- Tasks include retry logic
- Tasks live in app/tasks.py
- RabbitMQ is the Message Broker
- Flower Monitoring: Use for debugging failed tasks
## Testing
- Framework: Django TestCase (not pytest)
- Separate test files per module: test_models.py, test_views.py, test_forms.py
## Frontend Standards
### New Projects (DaisyUI + Tailwind)
- DaisyUI 4 via CDN for component classes
- Tailwind CSS via CDN for utility classes
- Theme management via Themis (DaisyUI `data-theme` attribute)
- All apps extend `themis/base.html` for consistent navigation
- No inline styles or scripts
### Existing Projects (Bootstrap 5)
- Bootstrap 5 via CDN
- Bootstrap Icons via CDN
- Bootswatch for theme variants (if applicable)
- django-bootstrap5 and crispy-bootstrap5 for form rendering
## Preferred Packages
### Core Django
- django>=5.2,<6.0
- django-environ — Environment variables
### Authentication & Security
- django-allauth — User management
- django-allauth-2fa — Two-factor authentication
### API Development
- djangorestframework>=3.14,<4.0 — REST APIs
- drf-spectacular — OpenAPI/Swagger documentation
### Encryption
- cryptography — Fernet encryption for secrets/API keys
### Background Tasks
- celery — Async task queue
- django-celery-progress — Progress bars
- flower — Celery monitoring
### Caching
- pymemcache — Memcached backend
### Database
- psycopg[binary] — PostgreSQL adapter
- shortuuid — Short UUIDs for public URLs
### Production
- gunicorn — WSGI server
### Shared Apps
- django-heluca-themis — User preferences, themes, key management, navigation
### Deprecated / Removed
- ~~pytz~~ — Use stdlib `zoneinfo` (Python 3.9+, Django 4+)
- ~~Pillow~~ — Only add if your app needs ImageField
- ~~django-heluca-core~~ — Replaced by Themis
- ~~dj-database-url~~ — Use individual Django DB env vars instead
## Anti-Patterns to Avoid
### Models
- Don't use `Model.objects.get()` without handling `DoesNotExist`
- Don't use `null=True` on `CharField` or `TextField` (use `blank=True, default=""`)
- Don't use `related_name='+'` unless you have a specific reason
- Don't override `save()` for business logic (use signals or service functions)
- Don't use `auto_now=True` on fields you might need to manually set
- Don't use `ForeignKey` without specifying `on_delete` explicitly
- Don't use `Meta.ordering` on large tables (specify ordering in queries)
### Queries
- Don't query inside loops (N+1 problem)
- Don't use `.all()` when you need a subset
- Don't use raw SQL unless absolutely necessary
- Don't forget `select_related()` and `prefetch_related()`
### Views
- Don't put business logic in views
- Don't use `request.POST.get()` without validation (use forms)
- Don't return sensitive data in error messages
- Don't forget `login_required` decorator on protected views
### Forms
- Don't use `fields = '__all__'` in ModelForm
- Don't trust client-side validation alone
- Don't use `exclude` in ModelForm (use explicit `fields`)
### Templates
- Don't use `{{ variable }}` for URLs (use `{% url %}` tag)
- Don't put logic in templates
- Don't use inline CSS or JavaScript (external files only)
- Don't forget `{% csrf_token %}` in forms
### Security
- Don't store secrets in `settings.py` (use environment variables)
- Don't commit `.env` files to version control
- Don't use `DEBUG=True` in production
- Don't expose sequential IDs in public URLs
- Don't use `mark_safe()` on user-supplied content
- Don't disable CSRF protection
### Imports & Code Style
- Don't use `from module import *`
- Don't use mutable default arguments
- Don't use bare `except:` clauses
- Don't ignore linter warnings without documented reason
### Migrations
- Don't edit migrations that have been deployed
- Don't use `RunPython` without a reverse function
- Don't add non-nullable fields without a default value
### Celery Tasks
- Don't pass model instances to tasks (pass IDs and re-fetch)
- Don't assume tasks run immediately
- Don't forget retry logic for external service calls