Django Project Organization¶
This guide describes modern Django project organization patterns for Django 5.2+ applications (2025). The patterns described here are based on production-grade architectures and reflect theoretical best practices for scalable, maintainable Django applications.
Overview¶
Django project organization has evolved beyond the traditional "one app per domain" model. Modern Django applications often benefit from a more nuanced approach that balances modularity, maintainability, and pragmatism.
Philosophy
The goal of project organization is to maximize developer productivity and code maintainability. Structure should serve the team, not constrain it.
Standard Django Project Layout¶
Minimal Structure¶
A standard Django project begins with this foundation:
myproject/
├── manage.py
├── myproject/
│ ├── __init__.py
│ ├── settings/
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── development.py
│ │ ├── production.py
│ │ └── testing.py
│ ├── urls.py
│ ├── wsgi.py
│ └── asgi.py
├── apps/
│ └── [application modules]
├── static/
├── templates/
└── tests/
Production-Grade Structure¶
A mature Django application extends this foundation:
myproject/
├── manage.py
├── myproject/
│ ├── __init__.py
│ ├── apps.py
│ ├── admin.py
│ ├── urls.py
│ ├── wsgi.py
│ ├── asgi.py
│ ├── db_routers.py
│ │
│ ├── settings/
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── development.py
│ │ ├── development_databases.py
│ │ ├── production.py
│ │ ├── production_databases.py
│ │ ├── testing.py
│ │ ├── testing_databases.py
│ │ └── logging_config.py
│ │
│ ├── models/
│ │ ├── __init__.py
│ │ ├── [domain_models]/
│ │ └── client_models/
│ │
│ ├── views/
│ │ ├── __init__.py
│ │ ├── api/
│ │ ├── abstracts/
│ │ ├── base/
│ │ └── [feature_views]/
│ │
│ ├── serializers/
│ │ ├── __init__.py
│ │ └── [serializers].py
│ │
│ ├── templates/
│ │ ├── base/
│ │ │ ├── partials/
│ │ │ └── sidebar_menu/
│ │ └── [feature_templates]/
│ │
│ ├── static/
│ │ ├── myproject/
│ │ │ ├── css/
│ │ │ ├── js/
│ │ │ └── img/
│ │ └── widgets/
│ │
│ ├── management/
│ │ ├── __init__.py
│ │ └── commands/
│ │ └── [commands].py
│ │
│ ├── migrations/
│ │ └── [migrations].py
│ │
│ ├── middleware/
│ │ ├── __init__.py
│ │ └── [middleware].py
│ │
│ ├── commons/
│ │ ├── audit_trail/
│ │ ├── config/
│ │ ├── decorators/
│ │ ├── exceptions/
│ │ ├── logging/
│ │ ├── mixins/
│ │ ├── pagination/
│ │ └── util/
│ │
│ ├── integrations/
│ │ ├── __init__.py
│ │ ├── [client_name]/
│ │ └── [service_name]/
│ │
│ ├── tasks/
│ │ ├── __init__.py
│ │ ├── [task_domain]/
│ │ └── clients/
│ │
│ ├── templatetags/
│ │ ├── __init__.py
│ │ └── [tags].py
│ │
│ ├── widgets/
│ │ ├── __init__.py
│ │ └── [widgets].py
│ │
│ ├── fixtures/
│ │ └── test/
│ │
│ ├── profiles/
│ │
│ └── vendor/
│ └── [vendored_packages]/
│
├── tests/
│ ├── __init__.py
│ ├── conftest.py
│ ├── fixtures/
│ │ └── cassettes/
│ ├── unit/
│ │ ├── api/
│ │ ├── forms/
│ │ ├── integrations/
│ │ ├── models/
│ │ ├── tasks/
│ │ ├── utils/
│ │ └── views/
│ ├── integration/
│ │ └── [integration_tests]/
│ └── ui/
│ └── [ui_tests]/
│
├── docs/
├── requirements/
│ ├── base.txt
│ ├── development.txt
│ ├── production.txt
│ └── testing.txt
│
└── [deployment_configs]/
Core Organization Principles¶
Single Application Pattern¶
Modern Approach
For many projects, a single Django application containing all business logic is more maintainable than multiple small apps.
The single application pattern works well when:
- Your project has a cohesive domain model
- Features are interconnected and share models
- You want to avoid import complexity between apps
- Your team values simplicity over artificial boundaries
Example: Poseidon Project
# settings/base.py
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"poseidon.apps.PoseidonConfig", # Single main application
"rest_framework",
# ... third-party apps
]
# apps.py
class PoseidonConfig(AppConfig):
name = "poseidon"
verbose_name = "Poseidon App"
label = "poseidon"
def ready(self):
# Import signal handlers
from . import models
When to Create Multiple Apps¶
Create separate Django applications when:
- Strong Domain Boundaries: Distinct business domains with minimal interdependency
- Reusability: Components intended for use across multiple projects
- Team Separation: Different teams own different domains
- Plugin Architecture: Need to enable/disable functionality dynamically
- Third-Party Distribution: Planning to open-source or package separately
Avoid Over-Appification
Creating too many small apps leads to circular dependency issues, complex imports, and cognitive overhead. Start with fewer apps and split only when necessary.
Models Organization¶
By Domain Pattern¶
Organize models into domain-specific subdirectories:
models/
├── __init__.py
├── api/
│ ├── __init__.py
│ └── api_abstracts_cache.py
├── planion/
│ ├── __init__.py
│ ├── planion_accounts.py
│ ├── planion_people.py
│ ├── planion_session.py
│ └── planion_container.py
├── client_models/
│ ├── __init__.py
│ ├── aossm/
│ │ └── aossm_payment_notifications.py
│ └── soa/
│ └── speaker_audit_trail.py
├── clickup/
│ ├── __init__.py
│ └── models_clickup.py
├── sso_saml/
│ ├── __init__.py
│ └── models.py
├── results_direct/
│ ├── __init__.py
│ └── [models].py
└── management/
├── __init__.py
└── planstone_management_command.py
Models __init__.py Pattern¶
The models package __init__.py should explicitly import and expose all models:
# models/__init__.py
from .bearer_authentication import BearerAuthentication
from .client_models.aossm.aossm_payment_notifications import AossmPaymentNotification
from .client_models.soa.speaker_audit_trail import SpeakerAuditTrail
from .management.planstone_management_command import PlanstoneManagementCommand
from .management.planstone_management_command import PlanstoneManagementCommandRun
from .planion.planion_accounts import Accounts
from .planion.planion_people import People
from .planion.planion_session import PlanionSession
from .planion.planion_container import PlanionContainer
from .sso_saml.models import SAMLConfiguration, SAMLAttributeMapping
from .clickup.models_clickup import ClickUpSpace, ClickUpFolder, ClickUpList
__all__ = [
"Accounts",
"BearerAuthentication",
"People",
"PlanionSession",
"PlanionContainer",
"AossmPaymentNotification",
"SpeakerAuditTrail",
"PlanstoneManagementCommand",
"PlanstoneManagementCommandRun",
"SAMLConfiguration",
"SAMLAttributeMapping",
"ClickUpSpace",
"ClickUpFolder",
"ClickUpList",
]
Why Explicit Imports?
Explicit imports in __init__.py ensure Django's model registry properly discovers all models, enable clean imports elsewhere in the codebase, and serve as documentation of available models.
Model Organization Strategies¶
1. Unmanaged Models for Legacy Databases¶
# models/planion/planion_people.py
from django.db import models
class People(models.Model):
pid = models.CharField(max_length=10, primary_key=True)
fname = models.CharField(max_length=31, blank=True, null=True)
lname = models.CharField(max_length=51, blank=True, null=True)
email = models.CharField(max_length=60, blank=True, null=True)
created_at = models.DateTimeField(blank=True, null=True)
updated_at = models.DateTimeField(blank=True, null=True)
class Meta:
managed = False
db_table = "people"
app_label = "poseidon"
def __str__(self):
return f"{self.fname} {self.lname}"
Always Set app_label
When organizing models in subdirectories, always set app_label in the Meta class to ensure Django associates the model with the correct application.
2. Client-Specific Models¶
# models/client_models/aossm/aossm_payment_notifications.py
from django.db import models
class AossmPaymentNotification(models.Model):
"""Tracks payment notifications from AOSSM integration."""
notification_id = models.CharField(max_length=100, unique=True)
transaction_date = models.DateTimeField()
amount = models.DecimalField(max_digits=10, decimal_places=2)
status = models.CharField(max_length=20)
class Meta:
app_label = "poseidon"
db_table = "aossm_payment_notifications"
3. Integration Models¶
# models/clickup/models_clickup.py
from django.db import models
class ClickUpSpace(models.Model):
"""Represents a ClickUp workspace space."""
space_id = models.CharField(max_length=50, unique=True)
name = models.CharField(max_length=255)
private = models.BooleanField(default=False)
class Meta:
app_label = "poseidon"
Views Organization¶
API Views Structure¶
Modern REST APIs benefit from feature-based organization:
views/
├── __init__.py
├── api/
│ ├── __init__.py
│ ├── urls.py
│ ├── base/
│ │ ├── __init__.py
│ │ └── base_viewsets.py
│ ├── abstracts/
│ │ ├── __init__.py
│ │ └── views.py
│ ├── people/
│ │ ├── __init__.py
│ │ ├── planion_people.py
│ │ └── permissions.py
│ ├── container/
│ │ ├── __init__.py
│ │ └── planion_container.py
│ ├── forms/
│ │ ├── __init__.py
│ │ └── planion_forms.py
│ ├── session/
│ │ ├── __init__.py
│ │ └── viewset_planion_session.py
│ └── integrations/
│ ├── __init__.py
│ └── [integration_viewsets].py
│
├── base/
│ ├── __init__.py
│ ├── urls.py
│ └── views.py
│
├── backstage/
│ ├── __init__.py
│ ├── urls.py
│ └── [admin_views]/
│
├── sso/
│ ├── __init__.py
│ ├── urls.py
│ ├── aaos/
│ ├── saml2/
│ └── [other_sso]/
│
├── embed/
│ ├── __init__.py
│ ├── urls.py
│ └── views.py
│
└── planion/
├── __init__.py
├── urls.py
├── accounts.py
└── utility/
ViewSet Pattern¶
# views/api/people/planion_people.py
import datetime
from django.db.models import Q
from django_filters import rest_framework as filters
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import status, viewsets
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from .permissions import PeopleAccessPolicy
from poseidon.commons.mixins.account_validation import AccountValidationMixin
from poseidon.commons.pagination.drf_pagination import CustomPageNumberPagination
from poseidon.serializers.planion_people import PlanionPeopleSerializer
from poseidon.models.planion.planion_people import People
from poseidon.commons.exceptions.api import BadRequestException
class PeopleFilter(filters.FilterSet):
"""Custom filterset for people endpoint."""
updated_after = filters.DateTimeFilter(field_name="updated_at", lookup_expr="gte")
any_email = filters.CharFilter(method="filter_by_any_email")
account = filters.CharFilter(method="filter_by_account")
class Meta:
model = People
fields = ["account", "pid", "clientpid", "clientpid2", "lname", "email", "username"]
class PlanionPeopleViewSet(AccountValidationMixin, viewsets.ReadOnlyModelViewSet):
"""
ViewSet for managing Planion people records.
Provides read-only access to people data with filtering and pagination.
"""
serializer_class = PlanionPeopleSerializer
permission_classes = [PeopleAccessPolicy]
filter_backends = [DjangoFilterBackend]
filterset_class = PeopleFilter
pagination_class = CustomPageNumberPagination
def get_queryset(self):
"""Return queryset filtered by account."""
account = self.request.query_params.get("account")
return People.objects.filter(account=account).using("planion")
URL Organization¶
Organize URLs hierarchically with namespaces:
# urls.py (root)
from django.urls import include, path
from poseidon.views.api.urls import api_v1_patterns
from poseidon.views.backstage.urls import backstage_urls
from poseidon.views.base.urls import base_url_patterns
from poseidon.views.planion.urls import planion_url_patterns
from poseidon.views.sso.urls import sso_urls
urlpatterns = [
path("api/v1/", include((api_v1_patterns, "api_v1"), namespace="api_v1")),
path("backstage/", include(backstage_urls)),
path("sso/", include(sso_urls)),
path("embed/", include("poseidon.views.embed.urls")),
path("integrations/clickup/", include("poseidon.integrations.clickup.urls")),
]
urlpatterns += base_url_patterns
urlpatterns += planion_url_patterns
# views/api/urls.py
from django.urls import path
from .people.planion_people import PlanionPeopleViewSet
from .container.planion_container import PlanionContainerViewSet
api_v1_patterns = [
path("people/", PlanionPeopleViewSet.as_view({"get": "list"})),
path("people/<str:pid>/", PlanionPeopleViewSet.as_view({"get": "retrieve"})),
path("containers/", PlanionContainerViewSet.as_view({"get": "list"})),
]
Templates Structure¶
Template Organization¶
templates/
├── base/
│ ├── base.html
│ ├── base_authenticated.html
│ ├── partials/
│ │ ├── header.html
│ │ ├── footer.html
│ │ ├── navigation.html
│ │ └── toast.html
│ └── sidebar_menu/
│ ├── main_menu.html
│ └── user_menu.html
│
├── errors/
│ ├── 403.html
│ ├── 404.html
│ └── 500.html
│
├── planion/
│ ├── accounts/
│ │ ├── list.html
│ │ └── detail.html
│ ├── exhibits/
│ └── utility/
│
├── backstage/
│ ├── clickup_issue/
│ ├── config_editor/
│ ├── rd_wizard/
│ └── scheduled_tasks/
│
├── integrations/
│ └── results_direct/
│
├── sso/
│ ├── saml/
│ ├── sgo/
│ └── tos/
│
├── embed/
│ └── people/
│
├── widgets/
│ └── [widget_templates].html
│
└── testing/
└── [test_templates].html
Template Patterns¶
Base Template¶
{# templates/base/base.html #}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}My Project{% endblock %}</title>
{% load static %}
<link rel="stylesheet" href="{% static 'myproject/css/main.css' %}">
{% block extra_css %}{% endblock %}
</head>
<body>
{% include "base/partials/header.html" %}
<main>
{% block content %}{% endblock %}
</main>
{% include "base/partials/footer.html" %}
<script src="{% static 'myproject/js/main.js' %}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
Feature Template¶
{# templates/planion/accounts/list.html #}
{% extends "base/base_authenticated.html" %}
{% block title %}Accounts{% endblock %}
{% block content %}
<div class="container mx-auto px-4">
<h1 class="text-3xl font-bold mb-6">My Accounts</h1>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for account in accounts %}
{% include "planion/accounts/_account_card.html" with account=account %}
{% endfor %}
</div>
</div>
{% endblock %}
Partial Template¶
{# templates/planion/accounts/_account_card.html #}
<div class="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow">
<h3 class="text-xl font-semibold mb-2">{{ account.name }}</h3>
<p class="text-gray-600 mb-4">{{ account.description }}</p>
<a href="{% url 'account_detail' account.id %}"
class="text-blue-600 hover:text-blue-800">
View Details →
</a>
</div>
Static Files Management¶
Static Files Structure¶
static/
├── myproject/
│ ├── css/
│ │ ├── main.css
│ │ ├── components/
│ │ └── version.txt
│ ├── js/
│ │ ├── main.js
│ │ ├── components/
│ │ └── utils/
│ └── img/
│ ├── logo.svg
│ └── icons/
│
└── widgets/
└── search/
├── search.css
└── search.js
Static Files Configuration¶
# settings/base.py
import os
from pathlib import Path
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PROJECT_PATH = os.path.abspath(os.path.dirname(__name__))
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(BASE_DIR, "static_root")
STATICFILES_DIRS = (
os.path.join(BASE_DIR, "static"),
os.path.join(PROJECT_PATH, "vendor"),
)
MEDIA_URL = "/mediafiles/"
MEDIA_ROOT = os.path.join(BASE_DIR, "mediafiles")
def get_tailwind_static_version():
"""Get CSS build version for cache busting."""
version_file = Path(BASE_DIR) / "static/css/version.txt"
if version_file.exists():
return version_file.read_text().strip()
return ""
TAILWIND_STATIC_VERSION = get_tailwind_static_version()
Template Static File Usage¶
{% load static %}
{# Versioned CSS for cache busting #}
<link rel="stylesheet"
href="{% static 'myproject/css/main.css' %}?v={{ TAILWIND_STATIC_VERSION }}">
{# Standard static file #}
<img src="{% static 'myproject/img/logo.svg' %}" alt="Logo">
{# JavaScript with module type #}
<script type="module" src="{% static 'myproject/js/main.js' %}"></script>
Management Commands Organization¶
Commands Structure¶
management/
├── __init__.py
├── commands/
│ ├── __init__.py
│ ├── createsu.py
│ ├── create_groups.py
│ ├── create_decision.py
│ ├── schedule_once.py
│ ├── planion_populate_search.py
│ ├── planion_stats.py
│ └── generate_rd_event_summary.py
│
└── cmd_archives/
└── [deprecated_commands].py
Management Command Pattern¶
# management/commands/createsu.py
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
class Command(BaseCommand):
"""Create a superuser account with predefined credentials."""
help = "Creates a superuser with email admin@example.com"
def add_arguments(self, parser):
parser.add_argument(
"--email",
type=str,
default="admin@example.com",
help="Email address for the superuser",
)
parser.add_argument(
"--password",
type=str,
default="admin123",
help="Password for the superuser",
)
def handle(self, *args, **options):
User = get_user_model()
email = options["email"]
password = options["password"]
if User.objects.filter(email=email).exists():
self.stdout.write(
self.style.WARNING(f"User with email {email} already exists")
)
return
user = User.objects.create_superuser(
username=email,
email=email,
password=password,
)
self.stdout.write(
self.style.SUCCESS(f"Successfully created superuser: {user.email}")
)
Domain-Specific Commands¶
# management/commands/planion_populate_search.py
from django.core.management.base import BaseCommand
from poseidon.integrations.planion.search import SearchIndexer
class Command(BaseCommand):
"""Populate search index with Planion data."""
help = "Populates the search index with people and session data"
def add_arguments(self, parser):
parser.add_argument(
"--account",
type=str,
required=True,
help="Account code to index",
)
parser.add_argument(
"--full-reindex",
action="store_true",
help="Perform full reindex instead of incremental",
)
def handle(self, *args, **options):
account = options["account"]
full_reindex = options["full_reindex"]
indexer = SearchIndexer(account)
if full_reindex:
self.stdout.write("Performing full reindex...")
indexer.reindex_all()
else:
self.stdout.write("Performing incremental update...")
indexer.update_recent()
self.stdout.write(
self.style.SUCCESS(f"Successfully indexed account: {account}")
)
Settings Modules¶
Environment-Based Settings¶
settings/
├── __init__.py
├── base.py
├── development.py
├── development_databases.py
├── production.py
├── production_databases.py
├── testing.py
├── testing_databases.py
└── logging_config.py
Base Settings¶
# settings/base.py
import os
from pathlib import Path
DEBUG = False
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ALLOWED_HOSTS = ["*"]
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"myproject.apps.MyProjectConfig",
"rest_framework",
"rest_framework.authtoken",
"django_filters",
"corsheaders",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "myproject.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "templates")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
}
]
WSGI_APPLICATION = "myproject.wsgi.application"
# Internationalization
LANGUAGE_CODE = "en-us"
USE_TZ = True
TIME_ZONE = "UTC"
USE_I18N = True
# REST Framework
REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 100,
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework.authentication.TokenAuthentication",
),
"DEFAULT_PERMISSION_CLASSES": (
"rest_framework.permissions.IsAuthenticated",
),
"DEFAULT_FILTER_BACKENDS": (
"django_filters.rest_framework.DjangoFilterBackend",
),
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
"rest_framework.renderers.BrowsableAPIRenderer",
],
}
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
Development Settings¶
# settings/development.py
from .base import *
from .development_databases import DATABASES
DEBUG = True
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "*.ngrok.io"]
# Development-specific apps
INSTALLED_APPS += [
"debug_toolbar",
"django_extensions",
]
MIDDLEWARE = [
"debug_toolbar.middleware.DebugToolbarMiddleware",
] + MIDDLEWARE
INTERNAL_IPS = ["127.0.0.1"]
# Simplified auth for development
SESSION_COOKIE_SECURE = False
CSRF_COOKIE_SECURE = False
# Email backend for development
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
# Logging configuration
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
},
"root": {
"handlers": ["console"],
"level": "INFO",
},
"loggers": {
"django": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
},
}
Production Settings¶
# settings/production.py
import os
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
from .base import *
from .production_databases import DATABASES
from .logging_config import get_logging_config
DEBUG = False
# Check if running in AWS Fargate
IN_AWS_FARGATE = "ECS_CONTAINER_METADATA_URI" in os.environ
if IN_AWS_FARGATE:
sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN"),
integrations=[DjangoIntegration()],
traces_sample_rate=0.5,
send_default_pii=True,
)
ALLOWED_HOSTS = [".example.com", ".elasticbeanstalk.com"]
# Security settings
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"
# CORS configuration
CORS_ALLOWED_ORIGIN_REGEXES = [
r"^https://[-\w]+\.example\.com$",
]
# Redis cache
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": os.environ.get("REDIS_URL"),
}
}
# Static files on S3
STATICFILES_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_STORAGE_BUCKET_NAME")
AWS_S3_REGION_NAME = os.environ.get("AWS_S3_REGION_NAME", "us-east-1")
# Logging
LOGGING = get_logging_config()
Database Settings¶
# settings/development_databases.py
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "myproject_dev",
"USER": "postgres",
"PASSWORD": "postgres",
"HOST": "localhost",
"PORT": "5432",
},
"planion": {
"ENGINE": "django.db.backends.mysql",
"NAME": "planion",
"USER": "root",
"PASSWORD": "password",
"HOST": "localhost",
"PORT": "3306",
},
}
DATABASE_ROUTERS = ["myproject.db_routers.PlanionRouter"]
Logging Configuration¶
# settings/logging_config.py
def get_logging_config():
"""Get logging configuration based on environment."""
return {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "{levelname} {asctime} {module} {message}",
"style": "{",
},
"simple": {
"format": "{levelname} {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "verbose",
},
"file": {
"class": "logging.handlers.RotatingFileHandler",
"filename": "logs/django.log",
"maxBytes": 1024 * 1024 * 15, # 15MB
"backupCount": 10,
"formatter": "verbose",
},
},
"root": {
"handlers": ["console", "file"],
"level": "INFO",
},
"loggers": {
"django": {
"handlers": ["console", "file"],
"level": "INFO",
"propagate": False,
},
"django.request": {
"handlers": ["console", "file"],
"level": "ERROR",
"propagate": False,
},
"myproject": {
"handlers": ["console", "file"],
"level": "DEBUG",
"propagate": False,
},
},
}
Tests Organization¶
Test Structure¶
tests/
├── __init__.py
├── conftest.py
├── README.md
│
├── fixtures/
│ ├── __init__.py
│ ├── cassettes/
│ │ └── [vcr_recordings].yaml
│ └── [fixture_files].json
│
├── unit/
│ ├── __init__.py
│ ├── api/
│ │ ├── __init__.py
│ │ ├── test_people_viewset.py
│ │ └── test_container_viewset.py
│ ├── models/
│ │ ├── __init__.py
│ │ ├── test_people.py
│ │ └── test_accounts.py
│ ├── forms/
│ │ ├── __init__.py
│ │ └── test_user_forms.py
│ ├── utils/
│ │ ├── __init__.py
│ │ └── test_helpers.py
│ ├── integrations/
│ │ ├── __init__.py
│ │ ├── test_clickup.py
│ │ └── test_results_direct.py
│ ├── tasks/
│ │ ├── __init__.py
│ │ └── test_scheduled_tasks.py
│ └── views/
│ ├── __init__.py
│ └── test_auth_views.py
│
├── integration/
│ ├── __init__.py
│ ├── clickup/
│ │ ├── __init__.py
│ │ └── test_clickup_integration.py
│ ├── results_direct/
│ │ ├── __init__.py
│ │ └── test_rd_integration.py
│ └── cmr/
│ ├── __init__.py
│ └── test_cmr_integration.py
│
└── ui/
├── __init__.py
├── clickup/
│ ├── __init__.py
│ └── test_clickup_ui.py
└── embed/
├── __init__.py
└── test_embed_ui.py
Test Configuration¶
# conftest.py
import pytest
from django.conf import settings
from django.test import Client
from rest_framework.test import APIClient
@pytest.fixture
def api_client():
"""Provide DRF API client."""
return APIClient()
@pytest.fixture
def authenticated_api_client(api_client, user):
"""Provide authenticated API client."""
api_client.force_authenticate(user=user)
return api_client
@pytest.fixture
def user(db):
"""Create test user."""
from django.contrib.auth import get_user_model
User = get_user_model()
return User.objects.create_user(
username="testuser",
email="test@example.com",
password="testpass123",
)
@pytest.fixture
def planion_db():
"""Provide Planion database connection."""
from django.db import connections
return connections["planion"]
@pytest.fixture(autouse=True)
def enable_db_access_for_all_tests(db):
"""Enable database access for all tests."""
pass
Unit Test Pattern¶
# tests/unit/api/test_people_viewset.py
import pytest
from django.urls import reverse
from rest_framework import status
@pytest.mark.django_db
class TestPlanionPeopleViewSet:
"""Tests for PlanionPeopleViewSet."""
def test_list_people_requires_authentication(self, api_client):
"""Test that listing people requires authentication."""
url = reverse("api_v1:people-list")
response = api_client.get(url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_list_people_requires_account_parameter(self, authenticated_api_client):
"""Test that account parameter is required."""
url = reverse("api_v1:people-list")
response = authenticated_api_client.get(url)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "account" in response.data["detail"].lower()
def test_list_people_returns_filtered_results(
self, authenticated_api_client, people_factory
):
"""Test that people are filtered by account."""
people_factory.create_batch(5, account="test_account")
people_factory.create_batch(3, account="other_account")
url = reverse("api_v1:people-list")
response = authenticated_api_client.get(url, {"account": "test_account"})
assert response.status_code == status.HTTP_200_OK
assert response.data["count"] == 5
def test_retrieve_person_by_pid(self, authenticated_api_client, people_factory):
"""Test retrieving a specific person by PID."""
person = people_factory.create(pid="12345", account="test_account")
url = reverse("api_v1:people-detail", kwargs={"pid": person.pid})
response = authenticated_api_client.get(url, {"account": "test_account"})
assert response.status_code == status.HTTP_200_OK
assert response.data["pid"] == "12345"
Integration Test Pattern¶
# tests/integration/clickup/test_clickup_integration.py
import pytest
import vcr
from poseidon.integrations.clickup.client import ClickUpClient
@pytest.mark.django_db
class TestClickUpIntegration:
"""Integration tests for ClickUp API."""
@vcr.use_cassette("tests/fixtures/cassettes/clickup_get_spaces.yaml")
def test_get_spaces(self, clickup_client):
"""Test fetching ClickUp spaces."""
spaces = clickup_client.get_spaces()
assert len(spaces) > 0
assert all("id" in space for space in spaces)
assert all("name" in space for space in spaces)
@vcr.use_cassette("tests/fixtures/cassettes/clickup_create_task.yaml")
def test_create_task(self, clickup_client):
"""Test creating a ClickUp task."""
task_data = {
"name": "Test Task",
"description": "Test Description",
"status": "Open",
}
task = clickup_client.create_task("list_id_123", task_data)
assert task["id"] is not None
assert task["name"] == "Test Task"
assert task["status"]["status"] == "Open"
@pytest.fixture
def clickup_client():
"""Provide ClickUp client instance."""
return ClickUpClient(api_token="test_token")
Utilities and Helpers¶
Commons Organization¶
commons/
├── __init__.py
├── audit_trail/
│ ├── __init__.py
│ └── audit.py
├── config/
│ ├── __init__.py
│ └── parameter_store.py
├── decorators/
│ ├── __init__.py
│ ├── cache.py
│ └── rate_limit.py
├── exceptions/
│ ├── __init__.py
│ └── api.py
├── logging/
│ ├── __init__.py
│ └── formatters.py
├── mixins/
│ ├── __init__.py
│ ├── account_validation.py
│ └── opentelemetry_params.py
├── pagination/
│ ├── __init__.py
│ └── drf_pagination.py
└── util/
├── __init__.py
├── date_helpers.py
└── string_helpers.py
Custom Exception Pattern¶
# commons/exceptions/api.py
from rest_framework import status
from rest_framework.exceptions import APIException
class BadRequestException(APIException):
"""Exception for HTTP 400 Bad Request errors."""
status_code = status.HTTP_400_BAD_REQUEST
default_detail = "Bad request"
default_code = "bad_request"
class UnprocessableEntityException(APIException):
"""Exception for HTTP 422 Unprocessable Entity errors."""
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
default_detail = "Unprocessable entity"
default_code = "unprocessable_entity"
class ResourceNotFoundException(APIException):
"""Exception for HTTP 404 Not Found errors."""
status_code = status.HTTP_404_NOT_FOUND
default_detail = "Resource not found"
default_code = "not_found"
Mixin Pattern¶
# commons/mixins/account_validation.py
from rest_framework.exceptions import PermissionDenied
from poseidon.commons.exceptions.api import BadRequestException
class AccountValidationMixin:
"""Mixin for validating account parameter in viewsets."""
def validate_account_param(self, request):
"""Validate that account parameter is provided and user has access."""
account = request.query_params.get("account")
if not account:
raise BadRequestException("Account parameter is required")
if not self.has_account_access(request.user, account):
raise PermissionDenied("You do not have access to this account")
return account
def has_account_access(self, user, account):
"""Check if user has access to the specified account."""
# Superusers have access to all accounts
if user.is_superuser or user.is_staff:
return True
# Check user's account permissions
return user.userprofile.accounts.filter(code=account).exists()
Pagination Pattern¶
# commons/pagination/drf_pagination.py
from rest_framework.pagination import PageNumberPagination
from rest_framework.response import Response
class CustomPageNumberPagination(PageNumberPagination):
"""Custom pagination class with configurable page size."""
page_size = 100
page_size_query_param = "page_size"
max_page_size = 1000
def get_paginated_response(self, data):
"""Return paginated response with metadata."""
return Response({
"count": self.page.paginator.count,
"next": self.get_next_link(),
"previous": self.get_previous_link(),
"page_size": self.page.paginator.per_page,
"total_pages": self.page.paginator.num_pages,
"current_page": self.page.number,
"results": data,
})
Integration Modules¶
Integration Structure¶
integrations/
├── __init__.py
├── clickup/
│ ├── __init__.py
│ ├── urls.py
│ ├── client.py
│ ├── models.py
│ ├── views.py
│ └── sync.py
│
├── planion/
│ ├── __init__.py
│ ├── client.py
│ └── search/
│ ├── __init__.py
│ └── indexer.py
│
├── resultsdirect/
│ ├── __init__.py
│ ├── client.py
│ └── wizard.py
│
├── sendgrid/
│ ├── __init__.py
│ └── client.py
│
└── [client_name]/
├── __init__.py
├── client.py
├── models.py
├── views.py
└── sync/
├── __init__.py
└── [sync_modules].py
Integration Client Pattern¶
# integrations/clickup/client.py
import requests
from typing import Dict, List, Optional
from django.conf import settings
from poseidon.commons.exceptions.api import BadRequestException
class ClickUpClient:
"""Client for interacting with ClickUp API."""
BASE_URL = "https://api.clickup.com/api/v2"
def __init__(self, api_token: Optional[str] = None):
"""Initialize ClickUp client."""
self.api_token = api_token or settings.CLICKUP_API_TOKEN
self.session = requests.Session()
self.session.headers.update({
"Authorization": self.api_token,
"Content-Type": "application/json",
})
def get_spaces(self, team_id: str) -> List[Dict]:
"""
Get all spaces for a team.
Args:
team_id: ClickUp team ID
Returns:
List of space dictionaries
"""
url = f"{self.BASE_URL}/team/{team_id}/space"
response = self.session.get(url)
response.raise_for_status()
return response.json()["spaces"]
def create_task(self, list_id: str, task_data: Dict) -> Dict:
"""
Create a task in a list.
Args:
list_id: ClickUp list ID
task_data: Task data dictionary
Returns:
Created task dictionary
"""
url = f"{self.BASE_URL}/list/{list_id}/task"
response = self.session.post(url, json=task_data)
response.raise_for_status()
return response.json()
def update_task(self, task_id: str, task_data: Dict) -> Dict:
"""
Update an existing task.
Args:
task_id: ClickUp task ID
task_data: Updated task data
Returns:
Updated task dictionary
"""
url = f"{self.BASE_URL}/task/{task_id}"
response = self.session.put(url, json=task_data)
response.raise_for_status()
return response.json()
Integration Sync Pattern¶
# integrations/clickup/sync.py
from typing import List
from django.db import transaction
from poseidon.integrations.clickup.client import ClickUpClient
from poseidon.models.clickup.models_clickup import ClickUpSpace, ClickUpList
class ClickUpSync:
"""Synchronize ClickUp data with local database."""
def __init__(self, api_token: str):
"""Initialize sync handler."""
self.client = ClickUpClient(api_token)
@transaction.atomic
def sync_spaces(self, team_id: str) -> List[ClickUpSpace]:
"""
Sync spaces from ClickUp API to database.
Args:
team_id: ClickUp team ID
Returns:
List of synced ClickUpSpace instances
"""
spaces_data = self.client.get_spaces(team_id)
synced_spaces = []
for space_data in spaces_data:
space, created = ClickUpSpace.objects.update_or_create(
space_id=space_data["id"],
defaults={
"name": space_data["name"],
"private": space_data.get("private", False),
},
)
synced_spaces.append(space)
return synced_spaces
Tasks Organization¶
Background Tasks Structure¶
tasks/
├── __init__.py
├── celery.py
├── planion/
│ ├── __init__.py
│ ├── sync_people.py
│ └── update_search_index.py
│
├── clickup/
│ ├── __init__.py
│ └── sync_tasks.py
│
└── clients/
├── __init__.py
├── aaos/
│ ├── __init__.py
│ └── sync_attendees.py
├── acmg/
│ ├── __init__.py
│ └── process_payments.py
└── sgo/
├── __init__.py
└── generate_reports.py
Celery Task Pattern¶
# tasks/celery.py
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings.production")
app = Celery("myproject")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
# tasks/planion/sync_people.py
from celery import shared_task
from django.db import connections
from poseidon.integrations.planion.client import PlanionClient
from poseidon.models.planion.planion_people import People
@shared_task(bind=True, max_retries=3)
def sync_people_task(self, account: str, incremental: bool = True):
"""
Sync people data from Planion to local database.
Args:
account: Account code to sync
incremental: If True, only sync recent changes
"""
try:
client = PlanionClient(account)
if incremental:
# Get last sync timestamp
last_sync = get_last_sync_timestamp(account)
people_data = client.get_people(updated_after=last_sync)
else:
people_data = client.get_all_people()
# Bulk update/create people records
with connections["planion"].cursor() as cursor:
for person in people_data:
People.objects.using("planion").update_or_create(
pid=person["pid"],
account=account,
defaults={
"fname": person.get("fname"),
"lname": person.get("lname"),
"email": person.get("email"),
"updated_at": person.get("updated_at"),
},
)
update_last_sync_timestamp(account)
return {"status": "success", "count": len(people_data)}
except Exception as exc:
# Retry with exponential backoff
raise self.retry(exc=exc, countdown=60 * (2 ** self.request.retries))
def get_last_sync_timestamp(account: str):
"""Get last sync timestamp for account."""
# Implementation here
pass
def update_last_sync_timestamp(account: str):
"""Update last sync timestamp for account."""
# Implementation here
pass
Decision Framework: Apps vs Modules¶
Decision Tree¶
graph TD
A[New Feature] --> B{Reusable across projects?}
B -->|Yes| C[Create separate app]
B -->|No| D{Strong domain boundary?}
D -->|Yes| E{10+ models?}
E -->|Yes| C
E -->|No| F[Use module within main app]
D -->|No| F
F --> G{Needs URL namespace?}
G -->|Yes| H[Create views/[feature] subdirectory]
G -->|No| I[Add to existing views module]
C --> J{Plugin-like functionality?}
J -->|Yes| K[Independent app with settings]
J -->|No| L[App within project]
When to Create a New App¶
| Scenario | Create App? | Rationale |
|---|---|---|
| User authentication/authorization | ✅ Yes | Highly reusable, clear boundary |
| Blog within e-commerce site | ✅ Yes | Distinct domain, optional feature |
| API endpoints for main models | ❌ No | Core functionality, tight coupling |
| Admin dashboard views | ❌ No | Supporting functionality |
| Third-party service integration | ⚠️ Maybe | Depends on complexity and reusability |
| Payment processing | ✅ Yes | Complex domain, regulatory concerns |
| Simple CRUD for one model | ❌ No | Unnecessary overhead |
Module Organization Within Single App¶
For features that don't warrant separate apps:
- Models by Domain: Organize in
models/[domain]/ - Views by Feature: Organize in
views/[feature]/ - Integration by Client: Organize in
integrations/[client]/ - Tasks by Domain: Organize in
tasks/[domain]/
Example Structure:
myapp/
├── models/
│ ├── ecommerce/
│ ├── users/
│ └── analytics/
├── views/
│ ├── api/
│ ├── storefront/
│ └── checkout/
├── integrations/
│ ├── stripe/
│ ├── sendgrid/
│ └── aws/
└── tasks/
├── inventory/
├── orders/
└── notifications/
Mono-repo vs Multi-repo¶
Mono-repo Strategy¶
Structure:
company-platform/
├── apps/
│ ├── main-web/
│ │ └── [Django project]
│ ├── admin-portal/
│ │ └── [Django project]
│ └── mobile-api/
│ └── [Django project]
│
├── packages/
│ ├── shared-auth/
│ │ └── [reusable Django app]
│ ├── common-models/
│ │ └── [reusable Django app]
│ └── ui-components/
│ └── [shared templates/static]
│
├── infrastructure/
│ ├── terraform/
│ └── kubernetes/
│
├── scripts/
└── docs/
Advantages:
- Shared code reuse without publishing packages
- Unified CI/CD and tooling
- Atomic cross-project changes
- Simplified dependency management
- Single source of truth
Disadvantages:
- Larger repository size
- More complex CI/CD (need to detect changes)
- Potential for tight coupling
- Team coordination overhead
Multi-repo Strategy¶
Structure:
company-main-web/
└── [Django project]
company-admin-portal/
└── [Django project]
company-mobile-api/
└── [Django project]
company-shared-auth/
└── [Reusable Django package]
company-common-models/
└── [Reusable Django package]
Advantages:
- Clear ownership boundaries
- Independent deployment cycles
- Smaller, faster repositories
- Easier access control
- Reduced blast radius for changes
Disadvantages:
- Dependency version management
- Need package registry
- Cross-repo refactoring is harder
- Code duplication risk
- Multiple CI/CD configurations
Recommendation¶
Use Mono-repo When
- Small to medium team (< 30 developers)
- Projects share significant code
- Coordinated releases preferred
- Infrastructure is shared
Use Multi-repo When
- Large organization (> 30 developers)
- Projects are truly independent
- Different deployment schedules
- Strong team boundaries
- Public/private code separation needed
Hybrid Approach¶
Many organizations use a hybrid:
company-platform/ (mono-repo)
├── web-app/
├── admin-portal/
└── shared-libraries/
company-mobile-api/ (separate repo)
└── [Independent API]
company-open-source/ (separate repo)
└── [Public Django packages]
Best Practices Summary¶
General Principles¶
- Start Simple: Begin with fewer apps/modules and split when necessary
- Follow Django Conventions: Use standard Django patterns unless there's a strong reason not to
- Explicit Over Implicit: Make imports and dependencies clear
- Document Structure: Maintain README files explaining organization
- Consistent Naming: Use clear, predictable naming conventions
Organization Guidelines¶
Models
- Organize by domain in subdirectories
- Always set
app_labelin Meta - Use
managed = Falsefor legacy databases - Explicitly import in
__init__.py
Views
- Organize API views by resource
- Group related views in subdirectories
- Use namespaced URL patterns
- Keep view files focused (< 500 lines)
Templates
- Create
base/partials/for reusable components - Organize by feature area
- Use consistent naming:
feature_template.html - Keep partials small and focused
Static Files
- Namespace under project name
- Version for cache busting
- Organize CSS/JS by component
- Use build tools for production
Tests
- Separate unit/integration/ui tests
- Mirror source code structure
- Use fixtures and factories
- Keep tests close to code they test
Settings
- Environment-based settings modules
- Never commit secrets
- Use environment variables
- Separate database configurations
Code Organization Checklist¶
- Clear separation of concerns
- Consistent directory structure
- Explicit
__init__.pyimports - Properly namespaced URLs
- Environment-specific settings
- Comprehensive test coverage
- Documentation for non-standard patterns
- Reusable components extracted
- Integration code isolated
- Background tasks organized by domain
Conclusion¶
Django project organization is not one-size-fits-all. The patterns described here represent modern best practices for Django 5.2+ applications in 2025, based on production experience. Adapt these patterns to your specific needs, team size, and project complexity.
Remember
Good organization serves the team. If a pattern isn't working, change it. The best structure is the one that makes your team most productive and your code most maintainable.