1. Mental Model: How Django Thinks

A practical, ground-up tutorial for the OBS Experiences platform backend.
Prerequisites & Scope This tutorial assumes comfort with Python. No prior Django/DRF experience is needed. We leverage PostgreSQL as our primary relational database. All examples map directly to the real OBS Experiences Product Vision (treks, bookings, loyalty tiers, emergency tracking), not toy demos.

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

Click on each pipeline node to see its underlying architectural duty in the OBS API lifecycle:

1

Routing (urls.py)

Resolves URI mapping and matches requests to views

2

Controller (views.py)

Applies filters, coordinates authentication, checks permissions

3

Translation Layer (serializers.py)

Serializes outbound models & validates incoming JSON payloads

4

Data Definition (models.py)

Encapsulates Postgres tables, integrity rules, and database functions

Click a node above to inspect its detailed backend role.

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
💡 Basecamp Knowledge Check: Why does Django utilize the Modular App architecture?
To keep all model schemas in a single massive database migrations table.
To split complex projects into self-contained, independently testable, domain-specific modules.
To ensure that Django can compile into a single static file format for cloud hosting.
🏕️ Basecamp Guide Tip: Correct! Modularity keeps development teams aligned, prevents git merge conflicts on shared models, and ensures that features like emergency tracking remain separate from B2B payment portals.

2. Project Setup & Structure

Setting up the development workspace, environment management, and folder architecture.

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
⚠️
Settings Overrides Warning Always inject development overrides into your execution process. Add the following line to 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'),
]
💡 Basecamp Knowledge Check: Why is splitting development and production settings considered best practice?
To allow the Django compiler to run faster on windows environments.
To isolate critical API credentials, prevent local developers from using production databases, and enable TLS/SSL security rules in public environments.
To bypass Django's default requirement of setting up a PostgreSQL server locally.
Basecamp Guide Tip: Correct! Exposing DEBUG = True, local passwords, or unsecure CORS headers on a public VPS can expose critical trekker data.

3. Models & Migrations

Defining database schemas, foreign relationships, and understanding the loyalty system logic.

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'
⛰️ Loyalty Tier Upgrade Simulator & Benefits
This simulator models the backend logic of the _check_tier_upgrade() method, connected to the brand benefits defined in the product specification. Slide the points range to see live updates:
Award OBS Points: 1,500
Manual Point Entry:
Trekker Status Tier: Trail Blazer
Active Point Total: 1,500
Tribe Discount: 5% DISCOUNT
Unlocked Benefits: Access to exclusive Tribe events, gear rental partner discounts.

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
💡 Basecamp Knowledge Check: Why is it important to snapshot (denormalize) the trekker info on the Booking model?
Because if a user updates their profile email, name, or phone 2 years later, the historical transactional booking logs must remain static for finance and security audit purposes.
Because Django ORM does not support queries linking Bookings back to Users.
To increase database size and optimize simple search indexing algorithms.
🗻 Basecamp Guide Tip: Spot on! Denormalizing essential transaction details protects historical accounting metrics and emergency records from post-facto edits.

4. DRF: Serializers, ViewSets & Routers

Converting database schemas into responsive REST API endpoints.

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
💡 Basecamp Knowledge Check: What is the primary purpose of @transaction.atomic in view methods?
To run the code parallelly across multiple processor threads.
To ensure that a sequence of database queries either completes entirely, or rolls back completely if any step fails.
To auto-generate documentation swagger schemas.
🏔️ Basecamp Guide Tip: Spot on! Without atomic safety rules, a crashed background worker could charge a trekker's wallet without saving their booking.

5. Permissions & Role-Based Access

Securing API endpoints using Role-Based Access Control.

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()]
💡 Basecamp Knowledge Check: What is the difference between has_permission and has_object_permission?
has_permission handles writes, while has_object_permission handles reads.
has_permission applies to the entire request sequence, while has_object_permission checks access to specific database object instances.
Django uses has_permission in development, and has_object_permission in production.
Basecamp Guide Tip: Spot on! has_permission filters incoming calls before accessing database rows, and has_object_permission checks access to individual items (like checking if Booking #42 belongs to the requesting user).

6. Auth: JWT + Google & OTP Authentication

Establishing secure, modern authentication pipelines for both web and mobile app clients.

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
        })
💡 Basecamp Knowledge Check: Why is cache.delete() executed immediately after validating the OTP?
To free up memory storage limits inside our Redis cluster database.
To enforce single-use integrity, preventing replay attacks where a intercepted code is submitted multiple times.
To trigger an automatic redirect inside client React Native routes.
🗻 Basecamp Guide Tip: Spot on! Deleting verification codes immediately after validation blocks malicious users from exploiting timing windows.

7. Celery: Background Jobs

Handling resource-intensive operations asynchronously using Celery and Redis.
🚀
Why Celery? To keep API response times low (under 100ms), we delegate slow external calls (like SMS dispatch or PDF generations) to a background task queue managed by Celery and Redis.

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
💡 Basecamp Knowledge Check: What is the primary role of Celery Beat?
To serve static frontend HTML files to browser clients.
To act as a scheduler, dispatching tasks to the worker queue on a cron-like schedule.
To monitor the live health of our PostgreSQL server.
Basecamp Guide Tip: Correct! Celery Beat functions as our cron engine, triggering daily reminders and midnight loyalty updates.

8. Quick Reference Cheatsheet

Common development commands, ORM patterns, and response codes.

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.