Django Backend Implementation Plan
Date: 2025-11-24 Context: Architecture Decision - Pivot from FastAPI to Django Purpose: Complete implementation roadmap for Django License Management API Timeline: 7-10 days
Executive Summaryβ
Decision: Rewrite backend from FastAPI β Django 5.2.8 with django-multitenant
Rationale:
- β Matches 120+ security hardening tasks in tasklist.md
- β Automatic row-level tenant isolation (django-multitenant)
- β Built-in CSRF, XSS, SQL injection protection
- β Security documentation already written and validated
- β Lower audit risk (proven patterns)
Timeline: 10-14 days total
- Days 1-3: Django project setup and core models
- Days 4-6: Django REST Framework API implementation
- Days 7: Testing and validation
- Days 8-10: Docker + Kubernetes deployment
- Days 11-14: Security hardening (7 layers)
Trade-off: Additional 3-4 days vs FastAPI, but 95/100 security score with high confidence.
π― Phase 1: Django Project Setup (Days 1-3)β
Day 1: Project Initializationβ
1.1 Create Django Project Structureβ
Location: /Users/halcasteel/PROJECTS/coditect-rollout-master/submodules/cloud/coditect-cloud-backend/
Commands:
# Navigate to backend repository
cd /Users/halcasteel/PROJECTS/coditect-rollout-master/submodules/cloud/coditect-cloud-backend
# Backup FastAPI code (for API reference)
mv src src_fastapi_backup
mv requirements.txt requirements_fastapi_backup.txt
# Create Django project
pip install django==5.2.8 django-multitenant==3.2.3
django-admin startproject license_platform .
# Create apps
python manage.py startapp licenses
python manage.py startapp tenants
python manage.py startapp users
python manage.py startapp api
Directory Structure:
coditect-cloud-backend/
βββ license_platform/ # Django project
β βββ __init__.py
β βββ settings/ # Split settings
β β βββ __init__.py
β β βββ base.py # Base settings
β β βββ development.py # Dev settings
β β βββ production.py # Production settings
β β βββ test.py # Test settings
β βββ urls.py
β βββ wsgi.py
β βββ asgi.py
βββ licenses/ # License management app
β βββ models.py # License, Session models
β βββ views.py # API views
β βββ serializers.py # DRF serializers
β βββ urls.py
β βββ services.py # Business logic
βββ tenants/ # Tenant/Organization app
β βββ models.py # Organization model
β βββ middleware.py # Tenant context
β βββ utils.py
βββ users/ # User management
β βββ models.py # User model
β βββ views.py
β βββ serializers.py
βββ api/ # API versioning
β βββ v1/
β β βββ urls.py
β β βββ views.py
β βββ urls.py
βββ manage.py
βββ requirements.txt # NEW Django deps
βββ pytest.ini
βββ .env.example
1.2 Create requirements.txtβ
# CODITECT License Platform - Django Backend Dependencies
# Django Core
Django==5.2.8
django-environ==0.11.2
psycopg[binary]==3.1.12
psycopg[pool]==3.1.12
# Multi-Tenancy
django-multitenant==3.2.3
# REST API
djangorestframework==3.16.1
djangorestframework-simplejwt==5.3.1
drf-spectacular==0.27.0
# CORS
django-cors-headers==4.3.1
# Redis
django-redis==5.4.0
redis==5.0.1
# Google Cloud
google-cloud-kms==2.20.0
google-cloud-secret-manager==2.17.0
google-auth==2.25.2
# Security
cryptography==41.0.7
# Production Server
gunicorn==21.2.0
uvicorn[standard]==0.25.0
# Testing
pytest==7.4.3
pytest-django==4.7.0
pytest-cov==4.1.0
pytest-asyncio==0.21.1
factory-boy==3.3.0
# Code Quality
black==23.12.1
flake8==6.1.0
mypy==1.7.1
django-stubs==4.2.7
1.3 Configure settings/base.pyβ
# license_platform/settings/base.py
import os
from pathlib import Path
import environ
# Build paths
BASE_DIR = Path(__file__).resolve().parent.parent.parent
# Environment variables
env = environ.Env(
DEBUG=(bool, False)
)
environ.Env.read_env(os.path.join(BASE_DIR, '.env'))
# Security
SECRET_KEY = env('DJANGO_SECRET_KEY')
DEBUG = env('DEBUG')
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['licenses.coditect.ai'])
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Third-party
'rest_framework',
'rest_framework_simplejwt',
'corsheaders',
'drf_spectacular',
'django_multitenant',
# Local apps
'tenants.apps.TenantsConfig',
'users.apps.UsersConfig',
'licenses.apps.LicensesConfig',
'api.apps.ApiConfig',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'tenants.middleware.TenantMiddleware', # Custom tenant context
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'license_platform.urls'
# Database
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': env('DATABASE_NAME', default='coditect'),
'USER': env('DATABASE_USER', default='app_user'),
'PASSWORD': env('DATABASE_PASSWORD'),
'HOST': env('DATABASE_HOST', default='localhost'),
'PORT': env('DATABASE_PORT', default='5432'),
'ATOMIC_REQUESTS': True,
'CONN_MAX_AGE': 600,
'OPTIONS': {
'connect_timeout': 10,
'sslmode': 'require',
}
}
}
# Cache (Redis)
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': env('REDIS_URL', default='redis://localhost:6378/0'),
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
'PASSWORD': env('REDIS_PASSWORD', default=''),
'SOCKET_CONNECT_TIMEOUT': 5,
'SOCKET_TIMEOUT': 5,
}
}
}
# REST Framework
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 50,
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
'EXCEPTION_HANDLER': 'api.exception_handler.custom_exception_handler',
}
# JWT Settings
from datetime import timedelta
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(hours=1),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
'ALGORITHM': 'RS256',
'SIGNING_KEY': env('JWT_PRIVATE_KEY'),
'VERIFYING_KEY': env('JWT_PUBLIC_KEY'),
}
# CORS
CORS_ALLOWED_ORIGINS = env.list('CORS_ALLOWED_ORIGINS', default=[
'https://licenses.coditect.ai',
'https://api.coditect.ai',
])
# Security Settings
SECURE_SSL_REDIRECT = not DEBUG
SESSION_COOKIE_SECURE = not DEBUG
CSRF_COOKIE_SECURE = not DEBUG
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = 'DENY'
# Cloud KMS
GCP_PROJECT_ID = env('GCP_PROJECT_ID', default='coditect-citus-prod')
KMS_KEY_NAME = env('KMS_KEY_NAME')
# Logging
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'json': {
'class': 'pythonjsonlogger.jsonlogger.JsonFormatter',
'format': '%(asctime)s %(name)s %(levelname)s %(message)s'
}
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'json',
},
},
'root': {
'handlers': ['console'],
'level': env('LOG_LEVEL', default='INFO'),
},
}
Day 2: Database Models with django-multitenantβ
2.1 Organization Model (Tenant)β
# tenants/models.py
from django.db import models
from django.contrib.postgres.fields import ArrayField
import uuid
class Organization(models.Model):
"""
Multi-tenant organization model.
Every other model will be scoped to an organization.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=255)
slug = models.SlugField(unique=True, max_length=100)
# Subscription details
subscription_tier = models.CharField(max_length=50, default='free')
max_concurrent_seats = models.IntegerField(default=5)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
is_active = models.BooleanField(default=True)
class Meta:
db_table = 'organizations'
ordering = ['name']
def __str__(self):
return self.name
2.2 User Modelβ
# users/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models
from django_multitenant.models import TenantModel
import uuid
class User(AbstractUser, TenantModel):
"""
Custom user model with multi-tenant support.
"""
tenant_id = 'organization_id' # django-multitenant
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
organization = models.ForeignKey(
'tenants.Organization',
on_delete=models.CASCADE,
related_name='users'
)
# Remove username, use email as identifier
username = None
email = models.EmailField(unique=True)
# User role within organization
ROLE_CHOICES = [
('owner', 'Owner'),
('admin', 'Admin'),
('member', 'Member'),
('guest', 'Guest'),
]
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='member')
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = []
class Meta:
db_table = 'users'
unique_together = ['organization', 'email']
def __str__(self):
return f"{self.email} ({self.organization.name})"
2.3 License Modelβ
# licenses/models.py
from django.db import models
from django_multitenant.models import TenantModel
import uuid
from datetime import datetime, timedelta
class License(TenantModel):
"""
License keys for CODITECT platform.
Automatically scoped to organization via django-multitenant.
"""
tenant_id = 'organization_id'
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
organization = models.ForeignKey(
'tenants.Organization',
on_delete=models.CASCADE,
related_name='licenses'
)
# License details
license_key = models.CharField(max_length=255, unique=True, db_index=True)
max_concurrent_seats = models.IntegerField(default=5)
# Validity
expires_at = models.DateTimeField(null=True, blank=True)
is_active = models.BooleanField(default=True)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(
'users.User',
on_delete=models.SET_NULL,
null=True,
related_name='created_licenses'
)
class Meta:
db_table = 'licenses'
ordering = ['-created_at']
indexes = [
models.Index(fields=['organization', 'license_key']),
models.Index(fields=['organization', 'is_active']),
]
def __str__(self):
return f"{self.license_key} ({self.organization.name})"
@property
def is_expired(self):
if not self.expires_at:
return False
return datetime.now(self.expires_at.tzinfo) > self.expires_at
@property
def is_valid(self):
return self.is_active and not self.is_expired
2.4 Session Model (Active License Usage)β
# licenses/models.py (continued)
class LicenseSession(TenantModel):
"""
Active license sessions tracking concurrent usage.
Uses Redis for real-time seat counting.
"""
tenant_id = 'organization_id'
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
organization = models.ForeignKey(
'tenants.Organization',
on_delete=models.CASCADE,
related_name='sessions'
)
license = models.ForeignKey(
License,
on_delete=models.CASCADE,
related_name='sessions'
)
user = models.ForeignKey(
'users.User',
on_delete=models.CASCADE,
related_name='sessions'
)
# Session details
hardware_id = models.CharField(max_length=255, db_index=True)
ip_address = models.GenericIPAddressField()
user_agent = models.TextField(blank=True)
# Timestamps
started_at = models.DateTimeField(auto_now_add=True)
last_heartbeat_at = models.DateTimeField(auto_now_add=True)
ended_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = 'license_sessions'
ordering = ['-started_at']
indexes = [
models.Index(fields=['organization', 'license', 'ended_at']),
models.Index(fields=['organization', 'hardware_id']),
]
def __str__(self):
return f"Session {self.id} - {self.user.email}"
@property
def is_active(self):
if self.ended_at:
return False
# Consider session stale if no heartbeat in 6 minutes
threshold = datetime.now(self.last_heartbeat_at.tzinfo) - timedelta(minutes=6)
return self.last_heartbeat_at > threshold
Day 3: Tenant Middlewareβ
# tenants/middleware.py
from django.http import JsonResponse
from rest_framework_simplejwt.authentication import JWTAuthentication
from django_multitenant.utils import set_current_tenant
import logging
logger = logging.getLogger(__name__)
class TenantMiddleware:
"""
Middleware to set tenant context from JWT token.
Ensures all queries are automatically filtered by organization.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Skip tenant context for public endpoints
if self._is_public_endpoint(request.path):
return self.get_response(request)
try:
# Extract tenant from JWT
auth = JWTAuthentication()
validated_token = auth.get_validated_token(
auth.get_raw_token(auth.get_header(request))
)
user = auth.get_user(validated_token)
# Set tenant context (django-multitenant magic)
set_current_tenant(user.organization)
# Attach to request for convenience
request.tenant = user.organization
request.user = user
except Exception as e:
logger.warning(f"Failed to set tenant context: {e}")
return JsonResponse(
{'detail': 'Invalid authentication'},
status=401
)
response = self.get_response(request)
return response
def _is_public_endpoint(self, path):
"""Check if endpoint doesn't require tenant context."""
public_paths = [
'/health/',
'/api/v1/auth/login',
'/api/v1/auth/register',
'/api/schema/',
'/api/docs/',
]
return any(path.startswith(p) for p in public_paths)
π― Phase 2: Django REST Framework API (Days 4-6)β
Day 4: Serializers and Viewsβ
4.1 License Serializersβ
# licenses/serializers.py
from rest_framework import serializers
from .models import License, LicenseSession
class LicenseSerializer(serializers.ModelSerializer):
is_expired = serializers.ReadOnlyField()
is_valid = serializers.ReadOnlyField()
active_sessions = serializers.SerializerMethodField()
class Meta:
model = License
fields = [
'id', 'license_key', 'max_concurrent_seats',
'expires_at', 'is_active', 'is_expired', 'is_valid',
'active_sessions', 'created_at', 'updated_at'
]
read_only_fields = ['id', 'created_at', 'updated_at']
def get_active_sessions(self, obj):
return obj.sessions.filter(ended_at__isnull=True).count()
class LicenseAcquireSerializer(serializers.Serializer):
license_key = serializers.CharField(max_length=255)
hardware_id = serializers.CharField(max_length=255)
def validate_license_key(self, value):
"""Validate license exists and is valid."""
try:
license = License.objects.get(license_key=value)
if not license.is_valid:
raise serializers.ValidationError("License is not active or has expired")
return value
except License.DoesNotExist:
raise serializers.ValidationError("Invalid license key")
class SessionSerializer(serializers.ModelSerializer):
class Meta:
model = LicenseSession
fields = [
'id', 'license', 'user', 'hardware_id',
'ip_address', 'started_at', 'last_heartbeat_at', 'ended_at'
]
read_only_fields = ['id', 'started_at', 'last_heartbeat_at']
4.2 License Viewsβ
# licenses/views.py
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from django.utils import timezone
from .models import License, LicenseSession
from .serializers import (
LicenseSerializer, LicenseAcquireSerializer, SessionSerializer
)
from .services import LicenseService
import logging
logger = logging.getLogger(__name__)
class LicenseViewSet(viewsets.ModelViewSet):
"""
API endpoints for license management.
Automatically scoped to tenant via django-multitenant.
"""
queryset = License.objects.all()
serializer_class = LicenseSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
"""Queryset automatically filtered by tenant."""
return super().get_queryset()
@action(detail=False, methods=['post'])
def acquire(self, request):
"""
Acquire a license session.
POST /api/v1/licenses/acquire
{
"license_key": "lic_...",
"hardware_id": "hw_..."
}
"""
serializer = LicenseAcquireSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
license_key = serializer.validated_data['license_key']
hardware_id = serializer.validated_data['hardware_id']
# Use service for business logic
service = LicenseService(request.tenant)
result = service.acquire_license(
user=request.user,
license_key=license_key,
hardware_id=hardware_id,
ip_address=request.META.get('REMOTE_ADDR'),
user_agent=request.META.get('HTTP_USER_AGENT', '')
)
if result['success']:
return Response({
'session_id': result['session_id'],
'signed_license': result['signed_license'],
'expires_at': result['expires_at'],
}, status=status.HTTP_201_CREATED)
else:
return Response({
'error': result['error'],
'reason': result.get('reason')
}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=False, methods=['post'])
def heartbeat(self, request):
"""
Send heartbeat to keep session alive.
POST /api/v1/licenses/heartbeat
{
"session_id": "uuid"
}
"""
session_id = request.data.get('session_id')
try:
session = LicenseSession.objects.get(
id=session_id,
user=request.user,
ended_at__isnull=True
)
session.last_heartbeat_at = timezone.now()
session.save()
return Response({'status': 'ok'})
except LicenseSession.DoesNotExist:
return Response(
{'error': 'Session not found or expired'},
status=status.HTTP_404_NOT_FOUND
)
@action(detail=False, methods=['post'])
def release(self, request):
"""
Release a license session.
POST /api/v1/licenses/release
{
"session_id": "uuid"
}
"""
session_id = request.data.get('session_id')
service = LicenseService(request.tenant)
success = service.release_license(session_id, request.user)
if success:
return Response({'status': 'released'})
else:
return Response(
{'error': 'Failed to release session'},
status=status.HTTP_400_BAD_REQUEST
)
Day 5: Business Logic Servicesβ
# licenses/services.py
from django.core.cache import cache
from django.db import transaction
from google.cloud import kms
import json
import base64
import hashlib
from datetime import datetime, timedelta
from .models import License, LicenseSession
import logging
logger = logging.getLogger(__name__)
class LicenseService:
"""
Business logic for license acquisition and management.
Handles atomic seat counting with Redis.
"""
def __init__(self, organization):
self.organization = organization
self.kms_client = kms.KeyManagementServiceClient()
def acquire_license(self, user, license_key, hardware_id, ip_address, user_agent):
"""
Acquire a license session with atomic seat counting.
"""
try:
# Get license
license = License.objects.get(
license_key=license_key,
organization=self.organization
)
# Check validity
if not license.is_valid:
return {
'success': False,
'error': 'License invalid or expired'
}
# Atomic seat check using Redis Lua script
can_acquire = self._atomic_seat_check(license)
if not can_acquire:
return {
'success': False,
'error': 'No available seats',
'reason': f'Maximum {license.max_concurrent_seats} concurrent users'
}
# Create session
with transaction.atomic():
session = LicenseSession.objects.create(
organization=self.organization,
license=license,
user=user,
hardware_id=hardware_id,
ip_address=ip_address,
user_agent=user_agent
)
# Sign license with Cloud KMS
signed_license = self._sign_license(session)
logger.info(f"License acquired: {session.id} for {user.email}")
return {
'success': True,
'session_id': str(session.id),
'signed_license': signed_license,
'expires_at': license.expires_at.isoformat() if license.expires_at else None
}
except License.DoesNotExist:
return {
'success': False,
'error': 'License not found'
}
except Exception as e:
logger.error(f"Failed to acquire license: {e}")
return {
'success': False,
'error': 'Internal error acquiring license'
}
def _atomic_seat_check(self, license):
"""
Atomic seat counting with Redis Lua script.
Prevents race conditions.
"""
redis_key = f"license:{license.id}:active_seats"
# Lua script for atomic increment-if-under-limit
lua_script = """
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local current = tonumber(redis.call('GET', key) or 0)
if current < limit then
redis.call('INCR', key)
redis.call('EXPIRE', key, 360)
return 1
else
return 0
end
"""
result = cache.client.get_client().eval(
lua_script,
1,
redis_key,
license.max_concurrent_seats
)
return bool(result)
def release_license(self, session_id, user):
"""
Release a license session and decrement seat count.
"""
try:
session = LicenseSession.objects.get(
id=session_id,
user=user,
organization=self.organization,
ended_at__isnull=True
)
session.ended_at = datetime.now()
session.save()
# Decrement Redis counter
redis_key = f"license:{session.license.id}:active_seats"
cache.client.get_client().decr(redis_key)
logger.info(f"License released: {session_id}")
return True
except LicenseSession.DoesNotExist:
logger.warning(f"Session not found: {session_id}")
return False
def _sign_license(self, session):
"""
Sign license data with Cloud KMS (RSA-4096).
Returns tamper-proof signed license.
"""
license_data = {
'session_id': str(session.id),
'license_key': session.license.license_key,
'user_email': session.user.email,
'organization': self.organization.slug,
'expires_at': session.license.expires_at.isoformat() if session.license.expires_at else None,
'issued_at': session.started_at.isoformat(),
}
# Serialize and hash
data_bytes = json.dumps(license_data, sort_keys=True).encode('utf-8')
digest = hashlib.sha512(data_bytes).digest()
# Sign with Cloud KMS
from django.conf import settings
key_name = settings.KMS_KEY_NAME
sign_response = self.kms_client.asymmetric_sign(
request={
'name': key_name,
'digest': {'sha512': digest}
}
)
signature = base64.b64encode(sign_response.signature).decode('utf-8')
return {
'data': license_data,
'signature': signature
}
π Phase 3: Testing (Day 7)β
Test Suite Structureβ
# tests/conftest.py
import pytest
from rest_framework.test import APIClient
from tenants.models import Organization
from users.models import User
from licenses.models import License
from django_multitenant.utils import set_current_tenant
@pytest.fixture
def api_client():
return APIClient()
@pytest.fixture
def organization():
org = Organization.objects.create(
name='Test Org',
slug='test-org',
max_concurrent_seats=5
)
return org
@pytest.fixture
def user(organization):
set_current_tenant(organization)
user = User.objects.create_user(
email='test@example.com',
password='testpass123',
organization=organization,
role='admin'
)
return user
@pytest.fixture
def license(organization):
set_current_tenant(organization)
license = License.objects.create(
organization=organization,
license_key='test-lic-key-123',
max_concurrent_seats=3,
is_active=True
)
return license
# tests/test_license_acquisition.py
import pytest
from django.urls import reverse
from licenses.models import LicenseSession
@pytest.mark.django_db
class TestLicenseAcquisition:
def test_acquire_license_success(self, api_client, user, license):
"""Test successful license acquisition."""
api_client.force_authenticate(user=user)
url = reverse('license-acquire')
data = {
'license_key': license.license_key,
'hardware_id': 'hw-test-123'
}
response = api_client.post(url, data, format='json')
assert response.status_code == 201
assert 'session_id' in response.data
assert 'signed_license' in response.data
# Verify session created
assert LicenseSession.objects.filter(
user=user,
license=license
).exists()
def test_acquire_license_no_seats(self, api_client, user, license):
"""Test license acquisition when no seats available."""
# Fill all seats
for i in range(license.max_concurrent_seats):
LicenseSession.objects.create(
organization=user.organization,
license=license,
user=user,
hardware_id=f'hw-{i}'
)
api_client.force_authenticate(user=user)
url = reverse('license-acquire')
data = {
'license_key': license.license_key,
'hardware_id': 'hw-overflow'
}
response = api_client.post(url, data, format='json')
assert response.status_code == 400
assert 'No available seats' in response.data['error']
π¦ Phase 4: Docker + Kubernetes (Days 8-10)β
Dockerfileβ
# Dockerfile
FROM python:3.11-slim
# Security: run as non-root
RUN useradd -m -u 1000 django && \
mkdir -p /app && \
chown -R django:django /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
postgresql-client \
libpq-dev \
gcc \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy requirements
COPY --chown=django:django requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY --chown=django:django . .
# Switch to non-root user
USER django
# Collect static files
RUN python manage.py collectstatic --noinput
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD python manage.py health_check || exit 1
# Expose port
EXPOSE 8000
# Run gunicorn
CMD ["gunicorn", "license_platform.wsgi:application", \
"--bind", "0.0.0.0:8000", \
"--workers", "4", \
"--timeout", "60", \
"--access-logfile", "-", \
"--error-logfile", "-", \
"--log-level", "info"]
β Success Criteriaβ
Phase 1 (Days 1-3):
- Django project structure created
- django-multitenant configured
- All models defined with proper relationships
- Migrations created and tested
- Tenant middleware working
Phase 2 (Days 4-6):
- Django REST Framework API implemented
- JWT authentication working
- License acquisition flow complete
- Redis atomic seat counting operational
- Cloud KMS signing integration
Phase 3 (Day 7):
- 80%+ test coverage
- All API endpoints tested
- Multi-tenant isolation verified
- Load testing passed (100+ concurrent users)
Phase 4 (Days 8-10):
- Docker image built and tagged
- Kubernetes manifests created
- Deployed to GKE
- Health checks passing
- End-to-end integration test passed
π Timeline Estimateβ
| Phase | Duration | Completion Criteria |
|---|---|---|
| Phase 1: Setup | 3 days | Models + migrations + middleware |
| Phase 2: API | 3 days | REST API + business logic |
| Phase 3: Testing | 1 day | 80% coverage + load tests |
| Phase 4: Deploy | 3 days | Docker + K8s + production |
| Total | 10 days | Working Django backend on GKE |
Add 3-4 days for security hardening (Days 11-14).
Total project timeline: 14 days to production-ready with 95/100 security score.
π Next Stepsβ
- Get stakeholder approval for 14-day timeline
- Backup FastAPI code (src β src_fastapi_backup)
- Start Phase 1 (Django project setup)
- Daily progress checkpoints via todo list
- Deploy to dev GKE at end of Phase 4
- Security hardening (Days 11-14, 120+ tasks from tasklist.md)
Document Control:
- Version: 1.0
- Created: 2025-11-24
- Status: APPROVED - Ready for Implementation
- Next Review: 2025-11-27 (after Phase 1 complete)