1. Mental Model: How Django Thinks
Before writing database structures or API routes, we must understand Django's fundamental architecture. Developing the backend for OBS means building the digital home of the Trekking Tribe.
The MTV Pattern
Django utilizes the Model-Template-View (MTV) pattern. However, because we are building a clean headless REST API to power our Mobile Apps (iOS/Android) and modern Next.js website, we skip the HTML Template rendering completely. Our pipeline resolves into a robust Model-View (MV) architecture, with Django REST Framework's (DRF) Serializer acting as our translation gateway.
Interactive Request-Response Lifecycle Flow
Routing (urls.py)
Resolves URI mapping and matches requests to views
Controller (views.py)
Applies filters, coordinates authentication, checks permissions
Translation Layer (serializers.py)
Serializes outbound models & validates incoming JSON payloads
Data Definition (models.py)
Encapsulates Postgres tables, integrity rules, and database functions
Django's "Apps" Concept
Django splits a large, complex project into modular, self-contained sub-units called apps. Each app owns and operates its own dedicated model schemas, viewsets, filters, permissions, tests, and routing. In the OBS ecosystem, these apps map directly to the functional domains highlighted in our Product Requirements Document (PRD):
obs_backend/ ← Django project container (settings, root urls)
├── treks/ ← Trek catalog, batches inventory, regions, day itineraries
├── bookings/ ← Bookings transactions, payments gateway integrations, checklists
├── users/ ← User profiles, authentication systems, gamified loyalty tiers
├── community/ ← Social media feed, trip journal entries, explorer challenge badges
├── emergency/ ← Live offline SOS, mountain incident logs
└── notifications/ ← Real-time Celery SMS dispatcher, email notification pipelines
2. Project Setup & Structure
2.1 Environment Setup
Prepare a robust local workspace by establishing a Python virtual environment and installing our core dependency stack. This contains JWT auth systems, Redis connectors, database adapters, and documentation generation toolkits.
# Create and activate virtual environment
python -m venv obs_venv
source obs_venv/bin/activate # Windows: obs_venv\Scripts\activate
# Install core production-grade dependencies
pip install django djangorestframework
pip install psycopg2-binary # PostgreSQL adapter
pip install djangorestframework-simplejwt # Modern JWT authentication
pip install django-cors-headers # CORS configuration for web & mobile clients
pip install python-decouple # Secure environment variables decoupling
pip install Pillow # Image optimization operations
pip install celery redis # Event dispatching and scheduled background tasks
pip install social-auth-app-django # OpenID Connect, Google, & Apple OAuth
pip install drf-spectacular # OpenAPI 3 Schema & Swagger API sandbox generation
# Export dependencies
pip freeze > requirements.txt
2.2 Creating the Django Project
Initialize the workspace layout. Using a dot (.) is critical as it instructs Django to scaffold within our active workspace root directory instead of wrapping it inside redundant parent folders:
# Initialize root configuration
django-admin startproject obs_backend .
# Scaffold separate business apps matching the sitemap
python manage.py startapp treks
python manage.py startapp bookings
python manage.py startapp users
python manage.py startapp community
python manage.py startapp emergency
python manage.py startapp notifications
2.3 Project Directory Structure
Once setup completes, verify your workspace layout matches the tree below. Notice we partition our configuration environments inside a dedicated settings/ folder:
obs_backend_root/
├── manage.py ← Local utility CLI
├── requirements.txt ← Dependency list
├── .env ← SECRETS (Never commit to Git!)
├── .env.example ← Environment mock template for coworkers
│
├── obs_backend/ ← Core config directory
│ ├── __init__.py
│ ├── settings/
│ │ ├── __init__.py
│ │ ├── base.py ← Core shared configurations
│ │ ├── development.py ← Dev overrides (DEBUG = True)
│ │ └── production.py ← Prod overrides (DEBUG = False)
│ ├── urls.py ← Root URL router configuration
│ ├── wsgi.py
│ └── asgi.py
│
├── treks/ ← Modular catalog app
│ ├── models.py
│ ├── serializers.py
│ ├── views.py
│ ├── urls.py
│ ├── permissions.py
│ └── tests/
└── bookings/ ... users/ ... community/ ... emergency/ ... notifications/
2.4 Settings: Split by Environment
To support continuous deployment pipelines and avoid committing sensitive keys to public repositories, we split our environment settings. Examine how they look:
# obs_backend/settings/base.py
from pathlib import Path
from decouple import config
BASE_DIR = Path(__file__).resolve().parent.parent.parent
SECRET_KEY = config('SECRET_KEY')
INSTALLED_APPS = [
# Core system apps
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Third-party extensions
'rest_framework',
'rest_framework_simplejwt',
'corsheaders',
'social_django',
'drf_spectacular',
# OBS business domain modules
'users',
'treks',
'bookings',
'community',
'emergency',
'notifications',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', # Must operate first to handle pre-flights
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'obs_backend.urls'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': config('DB_NAME'),
'USER': config('DB_USER'),
'PASSWORD': config('DB_PASSWORD'),
'HOST': config('DB_HOST', default='localhost'),
'PORT': config('DB_PORT', default='5432'),
}
}
AUTH_USER_MODEL = 'users.User' # Critical to override before running first migration
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 20,
'DEFAULT_FILTER_BACKENDS': [
'rest_framework.filters.SearchFilter',
'rest_framework.filters.OrderingFilter',
],
}
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
'REFRESH_TOKEN_LIFETIME': timedelta(days=30),
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
}
CELERY_BROKER_URL = config('REDIS_URL', default='redis://localhost:6379/0')
CELERY_RESULT_BACKEND = config('REDIS_URL', default='redis://localhost:6379/0')
CELERY_TASK_SERIALIZER = 'json'
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
TIME_ZONE = 'Asia/Kolkata'
USE_TZ = True
# obs_backend/settings/development.py
from .base import *
DEBUG = True
ALLOWED_HOSTS = ['*']
# Open CORS permissions for client-side simulator testing
CORS_ALLOW_ALL_ORIGINS = True
# Route outgoing emails directly to standard console
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# obs_backend/settings/production.py
from .base import *
DEBUG = False
ALLOWED_HOSTS = config('ALLOWED_HOSTS', cast=lambda v: [s.strip() for s in v.split(',')])
CORS_ALLOWED_ORIGINS = config(
'CORS_ALLOWED_ORIGINS',
cast=lambda v: [s.strip() for s in v.split(',')]
)
# Enhanced production headers for VPS deployments
SECURE_HSTS_SECONDS = 31536000
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# .env configuration (Keep this out of git records!)
SECRET_KEY=obs-custom-vps-production-key-token-here
DB_NAME=obs_db
DB_USER=obs_postgres_user
DB_PASSWORD=highly-secure-passphrase-here
DB_HOST=localhost
DB_PORT=5432
REDIS_URL=redis://localhost:6379/0
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-google-client-secret
manage.py and wsgi.py to prevent Django from defaulting to a missing settings file:
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'obs_backend.settings.development')
2.5 Root URLs
Assemble the central routing engine that directs traffic into each micro-service and scaffolds auto-generated API specifications:
# obs_backend/urls.py
from django.contrib import admin
from django.urls import path, include
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerUIView
urlpatterns = [
path('admin/', admin.site.urls),
# Core API Endpoints
path('api/v1/auth/', include('users.urls.auth')),
path('api/v1/users/', include('users.urls.users')),
path('api/v1/treks/', include('treks.urls')),
path('api/v1/bookings/', include('bookings.urls')),
path('api/v1/community/', include('community.urls')),
path('api/v1/emergency/', include('emergency.urls')),
path('api/v1/notifications/',include('notifications.urls')),
# Auto-Generated API Interactive Documentation sandboxes
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
path('api/docs/', SpectacularSwaggerUIView.as_view(url_name='schema'), name='docs'),
]
DEBUG = True, local passwords, or unsecure CORS headers on a public VPS can expose critical trekker data.
3. Models & Migrations
3.1 Custom User Model
The first rule of Django: Never use the default User model in a production app. Overriding it from day one gives us absolute freedom to build custom loyalty structures, points transaction records, and staff configurations.
# users/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models
class User(AbstractUser):
"""
OBS custom User Model. Extends AbstractUser.
Includes tribe statistics, profile details, emergency indexes, and loyalty milestones.
"""
class TribeTier(models.TextChoices):
BASECAMP = 'basecamp', 'Basecamp'
TRAIL_BLAZER = 'trail_blazer', 'Trail Blazer'
SUMMIT_SEEKER = 'summit_seeker', 'Summit Seeker'
PEAK_BAGGER = 'peak_bagger', 'Peak Bagger'
OBS_LEGEND = 'obs_legend', 'OBS Legend'
class ExperienceLevel(models.TextChoices):
NEVER = 'never', 'Never trekked before'
BEGINNER = 'beginner', '1-2 treks completed'
INTERMEDIATE = 'intermediate', '3-10 treks completed'
EXPERT = 'expert', 'I live in the high mountains'
class StaffRole(models.TextChoices):
SUPER_ADMIN = 'super_admin', 'Super Admin'
OPS_MANAGER = 'ops_manager', 'Operations Manager'
CUSTOMER_SUCCESS = 'cs', 'Customer Success Representative'
FINANCE = 'finance', 'Finance Officer'
CONTENT = 'content', 'Content Manager'
GUIDE = 'guide', 'Trek Guide'
# Contact & Profile
phone = models.CharField(max_length=15, blank=True)
bio = models.TextField(blank=True)
profile_photo = models.ImageField(upload_to='profiles/', null=True, blank=True)
tribe_handle = models.CharField(max_length=50, unique=True, null=True, blank=True)
date_of_birth = models.DateField(null=True, blank=True)
# Quiz-driven personalization
experience_level = models.CharField(
max_length=20,
choices=ExperienceLevel.choices,
default=ExperienceLevel.NEVER
)
# Gamified Loyalty Tiering (PRD aligned)
tribe_tier = models.CharField(
max_length=20,
choices=TribeTier.choices,
default=TribeTier.BASECAMP
)
obs_points = models.PositiveIntegerField(default=0)
staff_role = models.CharField(max_length=20, choices=StaffRole.choices, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Use email instead of default username for logins
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = ['username']
class Meta:
db_table = 'users'
def __str__(self):
return f"{self.email} ({self.tribe_tier})"
def add_points(self, amount: int, reason: str):
"""Award points to the trekker and log it for auditing."""
self.obs_points += amount
self.save(update_fields=['obs_points'])
PointsTransaction.objects.create(user=self, amount=amount, reason=reason)
self._check_tier_upgrade()
def _check_tier_upgrade(self):
"""Auto-upgrade user tiers based on point milestones."""
thresholds = [
(15000, self.TribeTier.OBS_LEGEND),
(7000, self.TribeTier.PEAK_BAGGER),
(3000, self.TribeTier.SUMMIT_SEEKER),
(1000, self.TribeTier.TRAIL_BLAZER),
]
for points, tier in thresholds:
if self.obs_points >= points:
if self.tribe_tier != tier:
self.tribe_tier = tier
self.save(update_fields=['tribe_tier'])
break
class PointsTransaction(models.Model):
"""Audit log of points adjustments."""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='points_history')
amount = models.IntegerField()
reason = models.CharField(max_length=200)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
class EmergencyContact(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='emergency_contacts')
name = models.CharField(max_length=100)
relationship = models.CharField(max_length=50)
phone = models.CharField(max_length=15)
email = models.EmailField(blank=True)
class Meta:
db_table = 'emergency_contacts'
3.2 Trek Catalog Models
Our core inventory centers on regions, treks, seasonal batches, and certified guides:
# treks/models.py
from django.db import models
class Region(models.Model):
name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(unique=True)
description = models.TextField(blank=True)
def __str__(self):
return self.name
class Trek(models.Model):
class Difficulty(models.TextChoices):
EASY = 'easy', 'Easy'
MODERATE = 'moderate', 'Moderate'
DIFFICULT = 'difficult', 'Difficult'
VERY_DIFFICULT = 'very_difficult', 'Very Difficult'
class Status(models.TextChoices):
DRAFT = 'draft', 'Draft Catalog'
ACTIVE = 'active', 'Active Public Catalog'
ARCHIVED = 'archived', 'Archived Catalog'
name = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
region = models.ForeignKey(Region, on_delete=models.PROTECT, related_name='treks')
description = models.TextField()
# Metrics
max_altitude = models.IntegerField(help_text="Meters above sea level")
total_distance = models.DecimalField(max_digits=6, decimal_places=1, help_text="Kilometers")
duration_days = models.PositiveSmallIntegerField()
difficulty = models.CharField(max_length=20, choices=Difficulty.choices)
max_group_size = models.PositiveSmallIntegerField(default=20)
# JSON Fields (Saves expensive joins on frequently-read arrays)
themes = models.JSONField(default=list) # ["snow", "waterfall", "summit"]
best_months = models.JSONField(default=list) # [5, 6, 9, 10]
inclusions = models.JSONField(default=list)
exclusions = models.JSONField(default=list)
packing_list = models.JSONField(default=list)
fitness_prep = models.TextField(blank=True)
faqs = models.JSONField(default=list)
gpx_file = models.FileField(upload_to='gpx/', null=True, blank=True)
status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRAFT)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'treks'
ordering = ['name']
def __str__(self):
return f"{self.name} ({self.difficulty})"
@property
def active_batches(self):
from django.utils import timezone
return self.batches.filter(
start_date__gte=timezone.now().date(),
status=Batch.Status.OPEN
)
class TrekItineraryDay(models.Model):
trek = models.ForeignKey(Trek, on_delete=models.CASCADE, related_name='itinerary')
day_number = models.PositiveSmallIntegerField()
title = models.CharField(max_length=200)
camp_name = models.CharField(max_length=200)
altitude = models.IntegerField()
distance_km = models.DecimalField(max_digits=5, decimal_places=1)
description = models.TextField()
meal_plan = models.CharField(max_length=100, blank=True)
class Meta:
db_table = 'trek_itinerary_days'
ordering = ['day_number']
unique_together = [['trek', 'day_number']]
class Guide(models.Model):
user = models.OneToOneField('users.User', on_delete=models.CASCADE, related_name='guide_profile')
bio = models.TextField()
photo = models.ImageField(upload_to='guides/', null=True, blank=True)
years_exp = models.PositiveSmallIntegerField(default=0)
regions = models.ManyToManyField(Region, blank=True)
certifications = models.JSONField(default=list)
satellite_phone = models.CharField(max_length=20, blank=True)
is_active = models.BooleanField(default=True)
def __str__(self):
return f"Guide: {self.user.get_full_name()}"
class Batch(models.Model):
class Status(models.TextChoices):
OPEN = 'open', 'Open'
FULL = 'full', 'Fully Booked'
ONGOING = 'ongoing', 'On Trek'
COMPLETED = 'completed', 'Completed'
CANCELLED = 'cancelled', 'Cancelled'
trek = models.ForeignKey(Trek, on_delete=models.CASCADE, related_name='batches')
guide = models.ForeignKey(Guide, on_delete=models.SET_NULL, null=True, related_name='batches')
start_date = models.DateField()
end_date = models.DateField()
total_seats = models.PositiveSmallIntegerField()
price = models.DecimalField(max_digits=8, decimal_places=2)
status = models.CharField(max_length=20, choices=Status.choices, default=Status.OPEN)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'batches'
ordering = ['start_date']
@property
def booked_seats(self):
return self.bookings.filter(status__in=['confirmed', 'pending']).count()
@property
def available_seats(self):
return self.total_seats - self.booked_seats
@property
def is_available(self):
return self.status == self.Status.OPEN and self.available_seats > 0
3.3 Booking & Payment Transaction Models
Handle bookings, health declarations, emergency contacts snapshotting, and transaction records:
# bookings/models.py
from django.db import models
class Booking(models.Model):
class Status(models.TextChoices):
PENDING = 'pending', 'Pending Payment'
CONFIRMED = 'confirmed', 'Confirmed'
CANCELLED = 'cancelled', 'Cancelled'
COMPLETED = 'completed', 'Completed'
class Source(models.TextChoices):
APP = 'app', 'Mobile App'
WEB = 'web', 'Website'
REFERRAL = 'referral', 'Referral'
user = models.ForeignKey('users.User', on_delete=models.PROTECT, related_name='bookings')
batch = models.ForeignKey('treks.Batch', on_delete=models.PROTECT, related_name='bookings')
status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING)
source = models.CharField(max_length=20, choices=Source.choices, default=Source.APP)
# Pricing audit
base_amount = models.DecimalField(max_digits=8, decimal_places=2)
discount_amount = models.DecimalField(max_digits=8, decimal_places=2, default=0)
final_amount = models.DecimalField(max_digits=8, decimal_places=2)
amount_paid = models.DecimalField(max_digits=8, decimal_places=2, default=0)
# Denormalized trekker facts snapshot (Ensures booking remains accurate if User alters profile)
trekker_name = models.CharField(max_length=200)
trekker_age = models.PositiveSmallIntegerField()
trekker_phone = models.CharField(max_length=15)
trekker_email = models.EmailField()
# Safety checks
health_declaration_signed = models.BooleanField(default=False)
waiver_signed = models.BooleanField(default=False)
id_document_uploaded = models.BooleanField(default=False)
# Snapshot emergency details
emergency_contact_name = models.CharField(max_length=100, blank=True)
emergency_contact_phone = models.CharField(max_length=15, blank=True)
emergency_contact_relation = models.CharField(max_length=50, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'bookings'
ordering = ['-created_at']
@property
def balance_due(self):
return self.final_amount - self.amount_paid
@property
def is_documents_complete(self):
return all([
self.health_declaration_signed,
self.waiver_signed,
self.id_document_uploaded,
])
class Payment(models.Model):
class Status(models.TextChoices):
PENDING = 'pending', 'Pending'
SUCCESS = 'success', 'Success'
FAILED = 'failed', 'Failed'
REFUNDED = 'refunded', 'Refunded'
booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name='payments')
razorpay_order_id = models.CharField(max_length=100, blank=True)
razorpay_payment_id = models.CharField(max_length=100, blank=True, unique=True)
amount = models.DecimalField(max_digits=8, decimal_places=2)
status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING)
payment_method = models.CharField(max_length=50, blank=True) # upi, card, netbanking
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'payments'
3.4 Database Migrations Workflow
Migrations act as the structural version control of your relational schema. Execute updates using this pipeline:
# Diffs models.py files and drafts incremental migration scripts
python manage.py makemigrations
# Applies migration transactions cleanly to your local PostgreSQL server
python manage.py migrate
# Inspects historical applied migrations log
python manage.py showmigrations
# Inspects raw SQL generated by a specific migration (Safe debugging check)
python manage.py sqlmigrate treks 0001
4. DRF: Serializers, ViewSets & Routers
Django REST Framework simplifies API building. Under this architecture, Serializers handle translations and validation, ViewSets specify business controllers, and Routers automate URL mapping.
4.1 Serializers
Our serializers convert complex Python structures into flat JSON objects, and validate incoming data schemas:
# treks/serializers.py
from rest_framework import serializers
from .models import Trek, Batch, TrekItineraryDay, Guide, Region
class RegionSerializer(serializers.ModelSerializer):
class Meta:
model = Region
fields = ['id', 'name', 'slug']
class TrekItineraryDaySerializer(serializers.ModelSerializer):
class Meta:
model = TrekItineraryDay
fields = ['day_number', 'title', 'camp_name', 'altitude', 'distance_km', 'description', 'meal_plan']
class GuideSerializer(serializers.ModelSerializer):
full_name = serializers.SerializerMethodField()
class Meta:
model = Guide
fields = ['id', 'full_name', 'bio', 'photo', 'years_exp', 'certifications']
def get_full_name(self, obj):
return obj.user.get_full_name()
# List Serializer: Lean & optimized to keep listing speeds high
class TrekListSerializer(serializers.ModelSerializer):
region = RegionSerializer(read_only=True)
difficulty = serializers.CharField(source='get_difficulty_display')
next_batch = serializers.SerializerMethodField()
seats_left = serializers.SerializerMethodField()
class Meta:
model = Trek
fields = ['id', 'name', 'slug', 'region', 'difficulty', 'max_altitude', 'duration_days', 'themes', 'next_batch', 'seats_left']
def get_next_batch(self, obj):
batch = obj.active_batches.first()
if batch:
return {'start_date': batch.start_date, 'price': batch.price}
return None
def get_seats_left(self, obj):
batch = obj.active_batches.first()
return batch.available_seats if batch else 0
# Detail Serializer: Full data payload with custom validations
class TrekDetailSerializer(serializers.ModelSerializer):
region = RegionSerializer(read_only=True)
region_id = serializers.PrimaryKeyRelatedField(
queryset=Region.objects.all(),
source='region',
write_only=True
)
itinerary = TrekItineraryDaySerializer(many=True, read_only=True)
difficulty = serializers.CharField(source='get_difficulty_display', read_only=True)
class Meta:
model = Trek
fields = [
'id', 'name', 'slug', 'region', 'region_id', 'description',
'max_altitude', 'total_distance', 'duration_days', 'difficulty',
'max_group_size', 'themes', 'best_months', 'inclusions',
'exclusions', 'packing_list', 'fitness_prep', 'faqs',
'gpx_file', 'itinerary', 'status', 'created_at'
]
read_only_fields = ['slug', 'created_at']
def validate_max_altitude(self, value):
"""Custom validator ensuring altitudes map correctly."""
if value < 0 or value > 9000:
raise serializers.ValidationError("Altitude must be within real Earth elevations (0m to 9000m).")
return value
4.2 ViewSets
ViewSets wrap our REST controllers. Observe how we utilize custom action routers and transaction rollbacks:
# treks/views.py
from rest_framework import viewsets, filters, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from .models import Trek, Batch
from .serializers import TrekListSerializer, TrekDetailSerializer, BatchSerializer
from users.permissions import IsOpsOrReadOnly
class TrekViewSet(viewsets.ModelViewSet):
queryset = Trek.objects.select_related('region').prefetch_related('itinerary')
permission_classes = [IsOpsOrReadOnly]
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ['name', 'region__name', 'themes']
ordering_fields = ['name', 'max_altitude', 'duration_days']
lookup_field = 'slug'
def get_serializer_class(self):
"""Serve light schema for lists, and deep schema for details."""
if self.action == 'list':
return TrekListSerializer
return TrekDetailSerializer
def get_queryset(self):
"""Filter drafts out of public views unless user is staff."""
qs = super().get_queryset()
if not self.request.user.is_staff:
qs = qs.filter(status='active')
# Filter by optional parameters
difficulty = self.request.query_params.get('difficulty')
region = self.request.query_params.get('region')
if difficulty:
qs = qs.filter(difficulty=difficulty)
if region:
qs = qs.filter(region__slug=region)
return qs
@action(detail=True, methods=['get'], url_path='batches')
def batches(self, request, slug=None):
"""GET /api/v1/treks/{slug}/batches/"""
trek = self.get_object()
batches = trek.batches.filter(status='open').order_by('start_date')
serializer = BatchSerializer(batches, many=True)
return Response(serializer.data)
4.3 Atomic Transaction Operations
In bookings, data consistency is critical. We use @transaction.atomic to ensure that if a payment record fails, the seat allocation is safely rolled back:
# bookings/views.py
from django.db import transaction
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from .models import Booking
from .serializers import BookingSerializer, BookingCreateSerializer
class BookingViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
def get_queryset(self):
if self.request.user.is_staff:
return Booking.objects.select_related('user', 'batch__trek').all()
return Booking.objects.filter(user=self.request.user).select_related('batch__trek')
@transaction.atomic
def create(self, request):
"""Safely register a booking, allocate loyalty points, and dispatch tasks."""
serializer = BookingCreateSerializer(data=request.data, context={'request': request})
serializer.is_valid(raise_exception=True)
batch = serializer.validated_data['batch']
if not batch.is_available:
return Response(
{'detail': 'Seat inventory exhausted for this batch.'},
status=status.HTTP_400_BAD_REQUEST
)
booking = serializer.save(user=request.user)
# Award tribe loyalty points
request.user.add_points(100, 'Trek Booking Completed')
# Trigger asynchronous Celery confirmation email
from notifications.tasks import send_booking_confirmation_email
send_booking_confirmation_email.delay(booking.id)
return Response(BookingSerializer(booking).data, status=status.HTTP_201_CREATED)
4.4 Auto-Wiring Routes
DRF's DefaultRouter auto-scaffolds URLs for all CRUD operations, reducing routing overhead:
# treks/urls.py
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register(r'', views.TrekViewSet, basename='trek')
urlpatterns = router.urls
5. Permissions & Role-Based Access
We restrict write operations to authorized personnel. To keep our code clean, we write declarative permission checks using DRF's BasePermission class.
5.1 OBS Custom Permission Declarations
Save these reusable permissions inside users/permissions.py:
# users/permissions.py
from rest_framework.permissions import BasePermission, SAFE_METHODS
class IsOpsOrReadOnly(BasePermission):
"""
Allow read-only (GET, HEAD) operations for all users.
Restrict writes to Super Admins and Operations Managers.
"""
def has_permission(self, request, view):
if request.method in SAFE_METHODS:
return True
return (
request.user.is_authenticated and
request.user.staff_role in ['super_admin', 'ops_manager']
)
class IsSuperAdmin(BasePermission):
def has_permission(self, request, view):
return (
request.user.is_authenticated and
request.user.staff_role == 'super_admin'
)
class IsOwnerOrStaff(BasePermission):
"""
Ensure users can only view or modify their own resource instances.
Staff and CS have global overrides.
"""
def has_object_permission(self, request, view, obj):
if request.user.is_staff or request.user.staff_role in ['super_admin', 'cs']:
return True
return obj.user == request.user
5.2 Applying Permissions to ViewSets
Incorporate the rules dynamically inside our viewsets:
# bookings/views.py
from rest_framework.permissions import IsAuthenticated
from users.permissions import IsOwnerOrStaff, IsSuperAdmin
class BookingViewSet(viewsets.ModelViewSet):
def get_permissions(self):
"""
Dynamically determine permissions based on request action:
- create/list/retrieve require standard authentication
- updates require ownership or staff roles
- deletion is restricted to Super Admins
"""
if self.action in ['update', 'partial_update']:
return [IsAuthenticated(), IsOwnerOrStaff()]
if self.action == 'destroy':
return [IsAuthenticated(), IsSuperAdmin()]
return [IsAuthenticated()]
6. Auth: JWT + Google & OTP Authentication
Our authentication pipeline supports three integration strategies: **JSON Web Tokens (JWT)** for standard sessions, **Google OAuth** for social logins, and **Redis-Backed OTP** for mobile-first auth.
6.1 Standard JWT Flow Serializers
# users/serializers.py
from rest_framework import serializers
from rest_framework_simplejwt.tokens import RefreshToken
from django.contrib.auth import authenticate
from .models import User
class LoginSerializer(serializers.Serializer):
email = serializers.EmailField()
password = serializers.CharField(write_only=True)
def validate(self, data):
user = authenticate(
request=self.context.get('request'),
username=data['email'],
password=data['password']
)
if not user:
raise serializers.ValidationError("Authentication failed. Invalid email or password.")
if not user.is_active:
raise serializers.ValidationError("This user account has been disabled.")
data['user'] = user
return data
def get_tokens(self, user):
refresh = RefreshToken.for_user(user)
return {
'access': str(refresh.access_token),
'refresh': str(refresh),
}
6.2 Google OAuth Verification View
Our app backend verifies Google OpenID tokens server-side before issuing a secure JWT:
# users/views.py
from google.auth.transport import requests as google_requests
from google.oauth2 import id_token
from django.conf import settings
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework_simplejwt.tokens import RefreshToken
class GoogleAuthView(APIView):
permission_classes = [AllowAny]
def post(self, request):
google_token = request.data.get('id_token')
if not google_token:
return Response({'detail': 'Google OAuth identity token is required.'}, status=400)
try:
# Verify token payload authenticity directly with Google
idinfo = id_token.verify_oauth2_token(
google_token,
google_requests.Request(),
settings.GOOGLE_CLIENT_ID
)
except ValueError:
return Response({'detail': 'Signature verification failed. Invalid Google token.'}, status=400)
email = idinfo.get('email')
google_id = idinfo.get('sub')
user, created = User.objects.get_or_create(
email=email,
defaults={
'username': email.split('@')[0],
'first_name': idinfo.get('given_name', ''),
'last_name': idinfo.get('family_name', ''),
}
)
refresh = RefreshToken.for_user(user)
return Response({
'user': UserProfileSerializer(user).data,
'access': str(refresh.access_token),
'refresh': str(refresh),
'is_new_user': created
})
6.3 Redis-Backed Phone OTP authentication
To support connectivity constraints in rural regions, we implement verification OTPs cached directly inside Redis:
# users/views.py (OTP Extension)
import random
from django.core.cache import cache
from rest_framework.views import APIView
from rest_framework.response import Response
class SendOTPView(APIView):
permission_classes = [AllowAny]
def post(self, request):
phone = request.data.get('phone')
if not phone:
return Response({'detail': 'Phone number parameter is required.'}, status=400)
# Generate standard 6-digit verification code
otp = str(random.randint(100000, 999999))
# Cache code in Redis with a 5-minute (300s) lifespan
cache.set(f'otp:{phone}', otp, timeout=300)
# Trigger async SMS notification task
from notifications.tasks import send_otp_sms
send_otp_sms.delay(phone, otp)
return Response({'detail': 'Verification code dispatched successfully.'})
class VerifyOTPView(APIView):
permission_classes = [AllowAny]
def post(self, request):
phone = request.data.get('phone')
otp = request.data.get('otp')
stored_otp = cache.get(f'otp:{phone}')
if not stored_otp or stored_otp != otp:
return Response({'detail': 'Verification code expired or invalid.'}, status=400)
# Invalidate code immediately upon use
cache.delete(f'otp:{phone}')
user, created = User.objects.get_or_create(
phone=phone,
defaults={
'username': f'trekker_{phone[-4:]}',
'email': f'{phone}@ohbhaisahab.com'
}
)
refresh = RefreshToken.for_user(user)
return Response({
'access': str(refresh.access_token),
'refresh': str(refresh),
'is_new_user': created
})
7. Celery: Background Jobs
7.1 Celery Settings
Register the task queue inside the Django configuration root:
# obs_backend/celery.py
import os
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'obs_backend.settings.development')
app = Celery('obs_backend')
app.config_from_object('django.conf:settings', namespace='CELERY')
# Auto-discover task declarations across all installed app modules
app.autodiscover_tasks()
7.2 Asynchronous Tasks
Register tasks inside tasks.py files. We include retry logic to handle transient network errors:
# notifications/tasks.py
from celery import shared_task
from django.core.mail import send_mail
from django.conf import settings
import requests
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def send_booking_confirmation_email(self, booking_id: int):
"""
Asynchronously email booking confirmations.
Includes exponential backoffs to handle mail server downtime.
"""
try:
from bookings.models import Booking
booking = Booking.objects.select_related('user', 'batch__trek').get(id=booking_id)
send_mail(
subject=f"Trek Confirmed: {booking.batch.trek.name}!",
message=f"Hi {booking.user.first_name},\n\nYour booking is confirmed! ID: #{booking.id}",
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[booking.user.email],
)
except Exception as exc:
raise self.retry(exc=exc, countdown=60 * (self.request.retries + 1))
@shared_task
def send_sos_alert(incident_id: int):
"""
Triggered by the emergency system when an SOS is activated.
Fires SMS alerts to emergency contacts and triggers push notifications to support staff.
"""
from emergency.models import Incident
incident = Incident.objects.select_related('trekker__emergency_contacts', 'batch__trek').get(id=incident_id)
# Dispatches SMS alerts to all listed emergency contacts
for contact in incident.trekker.emergency_contacts.all():
send_sms_raw.delay(
contact.phone,
f"URGENT: {incident.trekker.get_full_name()} activated SOS on {incident.batch.trek.name}! GPS: {incident.gps_coordinates}."
)
7.3 Celery Beat: Scheduled Cron Tasks
Register scheduled background jobs to handle recurring actions like loyalty tier updates and reminders:
# obs_backend/settings/base.py (Celery Beat Section)
from celery.schedules import crontab
CELERY_BEAT_SCHEDULE = {
# Check for upcoming treks and send reminder emails daily at 9:00 AM IST
'send-daily-pretrek-reminders': {
'task': 'notifications.tasks.dispatch_daily_reminders',
'schedule': crontab(hour=9, minute=0),
},
# Sync status changes across batches every hour
'sync-batch-availability': {
'task': 'treks.tasks.sync_batch_statuses',
'schedule': crontab(minute=0),
},
}
7.4 Running Celery in Production
Ensure you run the Celery processes alongside your web server in your deployment environment:
# Terminal 1: Gunicorn or standard development web server
python manage.py runserver
# Terminal 2: Celery worker (processes queued tasks)
celery -A obs_backend worker --loglevel=info
# Terminal 3: Celery beat (triggers scheduled tasks)
celery -A obs_backend beat --loglevel=info
8. Quick Reference Cheatsheet
New Feature Implementation Pipeline
Follow this checklist when adding new features or endpoints to the codebase:
1. Models: Declare schemas inside the appropriate apps/models.py file.
2. Migrations: Run 'makemigrations' and apply them with 'migrate'.
3. Serializers: Write data mapping structures inside apps/serializers.py.
4. Views: Implement controller logic inside apps/views.py.
5. Routing: Map URL paths inside urls.py and register them in the router.
6. Validation: Verify implementation behavior in the Swagger sandbox at /api/docs/
Django ORM Quick Reference
Common Django ORM query patterns for retrieving and updating data:
# 1. Retrieve all active treks in a specific region
treks = Trek.objects.filter(status='active', region__name='Ladakh')
# 2. Get an object by identifier or return a 404 response
trek = get_object_or_404(Trek, slug='kedarkantha')
# 3. Create a database record
booking = Booking.objects.create(user=user, batch=batch, base_amount=9500)
# 4. Perform a partial update, modifying only specific fields
booking.status = 'confirmed'
booking.save(update_fields=['status'])
# 5. Optimize queries using select_related to prevent N+1 issues (ForeignKeys)
bookings = Booking.objects.select_related('user', 'batch__trek').all()
# 6. Optimize queries using prefetch_related for reverse lookups (ManyToMany/Reverse Relations)
treks = Trek.objects.prefetch_related('itinerary', 'batches').all()
# 7. Aggregate data across tables
from django.db.models import Sum
revenue = Payment.objects.filter(status='success').aggregate(total=Sum('amount'))
Standard REST API Response Codes
We use appropriate HTTP status codes in our API responses:
| Status Code | Description | Use Case |
|---|---|---|
200 OK |
Request succeeded | Returning standard listing or detail JSON. |
201 Created |
Resource created | Successful booking or registration. |
400 Bad Request |
Validation failed | Invalid OTP, missing parameters, or full batches. |
401 Unauthorized |
Authentication required | Invalid or expired JWT access tokens. |
403 Forbidden |
Permission denied | Standard users attempting to access ops features. |
404 Not Found |
Resource not found | Requested trek slug does not exist. |