Skip to content

Latest commit

 

History

History
764 lines (622 loc) · 34.2 KB

File metadata and controls

764 lines (622 loc) · 34.2 KB

Moodify Backend (Django API)

Moodify

Serverless Django REST API for Moodify. Owns authentication, user profiles, mood / listening history, and proxies inference requests to the Modal ML service. Runs on Vercel against MongoDB Atlas — no SQL database, no in-process ML.


Table of contents

  1. What it is
  2. Why it looks the way it does
  3. Architecture
  4. Request lifecycle
  5. Data model
  6. Authentication
  7. Throttling + cost protection
  8. Endpoint reference
  9. Project layout
  10. Environment variables
  11. Running locally
  12. Testing
  13. OpenAPI / Swagger / Redoc
  14. Deployment (Vercel)
  15. SRE metrics
  16. Resilience + serverless gotchas
  17. Troubleshooting
  18. FAQ

What it is

A small, slim Django REST API. The deliberate design constraints:

  • No SQL database. All persistence is MongoDB Atlas via mongoengine. DATABASES = {} in settings.py — Django 5 permits it. The auth app is installed only because DRF + drf-yasg import from it; no SQL tables are ever queried.
  • No machine learning code. Inference (text / speech / face / recommendation) lives in the Modal service. This tier proxies to it and never imports torch / transformers / fer / librosa / opencv. That's what makes the Vercel bundle small enough to ship.
  • Stateless JWT auth against a Mongo User document — no sessions, no cookies, no CSRF surface.
  • Always-on Swagger / Redoc, loaded from a CDN so the docs page works on Vercel without collectstatic.

Why it looks the way it does

The previous backend was a heavyweight Django + SQLite app that imported torch, transformers, fer, librosa, opencv, moviepy, facenet-pytorch, and spotipy at startup. It worked on a Render dyno; it could not deploy to Vercel. The refactor split the responsibilities cleanly:

flowchart LR
    subgraph Old["Old (Render)"]
        OldD["Django + torch + tf + fer + sqlite +<br/>moviepy + opencv + spotipy"]
    end

    subgraph New["New"]
        NewD["Django<br/>(slim, serverless)"]
        Modal["Modal inference"]
        Atlas[("MongoDB Atlas")]
    end

    Old -- "refactor" --> New
    NewD <--> Atlas
    NewD <-->|JWT + service token| Modal

    style Old fill:#a8a8c0,stroke:#fff,color:#fff
    style New fill:#34d399,stroke:#fff,color:#fff
    style Modal fill:#7B68EE,stroke:#fff,color:#fff
    style Atlas fill:#47A248,stroke:#fff,color:#fff
Loading

Result: Vercel bundle dropped from 600+ MB to ~20 MB, cold start went from 30-60 s to 1-2 s, idle memory from 2-4 GB to 0.


Architecture

flowchart TB
    subgraph Edge["Vercel edge (serverless function)"]
        Vw["vercel_wsgi.py<br/>(@vercel/python entry)"]
        Wn["WhiteNoise"]
        Mw["MongoJWTAuthentication"]
    end

    subgraph Apps["Django apps"]
        ApiV["api/views.py<br/>(text + music proxy)"]
        UsersV["users/views.py<br/>(register/login/refresh/profile/history)"]
    end

    Vw --> Wn --> Mw --> ApiV
    Mw --> UsersV

    subgraph Store["MongoDB Atlas"]
        UColl[("users")]
        PColl[("user_profile")]
    end

    UsersV <--> UColl
    UsersV <--> PColl

    Modal["Modal inference<br/>(separate service)"]
    ApiV -- "MODAL_SERVICE_TOKEN" --> Modal

    Clients["Web / Mobile clients"]
    Clients -. "Bearer JWT" .-> Vw

    style Vw fill:#000,stroke:#fff,color:#fff
    style Modal fill:#7B68EE,stroke:#fff,color:#fff
    style Store fill:#47A248,stroke:#fff,color:#fff
Loading

The web frontend and mobile app call the Modal service directly with a user JWT — Django doesn't sit in the data path for media uploads (a Vercel function would time out on a 12 MB audio file anyway). Django can proxy text + music recommendation through api/views.py (/api/text_emotion/, /api/music_recommendation/), using the MODAL_SERVICE_TOKEN; this exists for server-to-server calls and tests.

All user state — mood history, listening history, saved recommendations — is read and written here, via mongoengine.


Request lifecycle

sequenceDiagram
    autonumber
    participant C as Client
    participant V as Vercel function
    participant DRF as DRF dispatcher
    participant Auth as MongoJWTAuthentication
    participant View as view
    participant Mongo as MongoDB Atlas
    participant Modal as Modal inference

    C->>V: POST /users/login/ {username|email, password}
    V->>DRF: dispatch
    DRF->>View: users.views.login(request)
    View->>Mongo: User by username, else email (retry if cold)
    Mongo-->>View: User doc
    View-->>C: 200 {access, refresh} (or 503 if still cold)

    Note over C,View: -- protected call --

    C->>V: GET /users/user/profile/<br/>Authorization: Bearer <JWT>
    V->>DRF: dispatch
    DRF->>Auth: decode JWT (HS256) -> sub
    Auth->>Mongo: User.objects(id=sub).first()
    Mongo-->>Auth: User doc
    Auth-->>DRF: (user, token)
    DRF->>View: users.views.user_profile(request)
    View->>Mongo: UserProfile.objects(username=user.username)
    Mongo-->>View: profile doc
    View-->>C: 200 {id, username, email, mood_history, ...}

    Note over C,View: -- recommendation proxy (rare path) --

    C->>V: POST /api/music_recommendation/ {emotion, history}
    V->>DRF: dispatch
    DRF->>View: api.views.music_recommendation
    View->>Modal: POST /music_recommendation<br/>Authorization: Bearer MODAL_SERVICE_TOKEN
    Modal-->>View: {emotion, recommendations, degraded}
    View-->>C: 200 {...}
Loading

Data model

The core documents, all via mongoengine (passkey + feedback collections are listed in the table below). Indexes managed in Atlas (the app sets auto_create_index=False, which is the right pattern for serverless — see serverless gotchas).

erDiagram
    USERS ||--|| USER_PROFILE : "1:1 via username"
    USERS ||--o{ WEBAUTHN_CREDENTIALS : "1:N passkeys via user_id"

    USERS {
        ObjectId _id
        string username "unique"
        string email "unique"
        string password "PBKDF2 hash"
        bool is_active
        datetime created_at "tz-aware UTC"
    }

    WEBAUTHN_CREDENTIALS {
        ObjectId _id
        string user_id "FK to USERS._id"
        string credential_id "unique, base64url"
        string public_key "COSE, base64url"
        int sign_count "anti-clone counter"
        list~string~ transports
        string name "user label"
        bool backed_up "synced keychain?"
        datetime last_used_at
    }

    USER_PROFILE {
        ObjectId _id
        string username "FK by value"
        list~string~ mood_history "append-only"
        list listening_history "track dicts; legacy strings"
        list~dict~ recommendations "rich track objects"
        dict mood_calibration "RL: {predicted: {actual: count}}"
        dict taste_profile "RL: {alpha[22], beta[22], events}"
        datetime created_at
    }
Loading
Collection Defined in Notes
users users/documents.py Auth-bearing; replaces django.contrib.auth.models.User. Password hashing reuses django.contrib.auth.hashers (pure functions).
user_profile api/models.py Re-exported from users/models.py so both apps share one definition. Owns the two RL fields below.
mood_feedback api/feedback_store.py Mongo time-series; one row per mood correction. Lazy-created. 365-day TTL.
track_feedback api/feedback_store.py Mongo time-series; one row per 👍 / 👎 / open-in-Deezer / clear (un-vote) signal. Lazy-created. 365-day TTL.
webauthn_credentials users/documents.py One row per enrolled passkey (many per user, keyed by user_id). Stores the COSE public key + signature counter; never any private key.
webauthn_challenges users/documents.py Short-lived, single-use ceremony challenges referenced by an opaque flowId. Deleted on complete; expired rows are swept opportunistically.

Personalisation fields (UserProfile.mood_calibration, UserProfile.taste_profile) are DictFields — schemaless, so no migration is needed when the layout evolves. The bandit's feature extractor is forward-compatible: shorter stored vectors are padded with the prior on read (api/bandit.py:_read_posterior).


Authentication

flowchart TD
    A["POST /users/login/<br/>{username | email, password}"] --> V{"verify"}
    V -- "ok" --> M["mint access (7d) + refresh (14d)"]
    V -- "bad creds" --> R1["401"]
    V -- "Mongo cold/unreachable<br/>(after retries)" --> R3["503 (waking up)"]
    M --> Tok["{access, refresh}"]

    Tok --> Use["Subsequent requests:<br/>Authorization: Bearer <access>"]
    Use --> D["MongoJWTAuthentication"]
    D -->|valid + type=access| OK["allow + request.user = User"]
    D -->|expired| R2["401"]
    Use -- "401" --> Ref["POST /users/token/refresh/<br/>{refresh}"]
    Ref --> M2["mint new access"]
    M2 --> Use

    style M fill:#34d399,stroke:#fff,color:#fff
    style OK fill:#34d399,stroke:#fff,color:#fff
    style R1 fill:#ef4444,stroke:#fff,color:#fff
    style R2 fill:#ef4444,stroke:#fff,color:#fff
    style R3 fill:#f59e0b,stroke:#fff,color:#fff
Loading
  • Username or email login. The credential field on POST /users/login/ accepts either: the lookup matches by username first, then falls back to a case-insensitive email match when the input contains @. (Users routinely type their email into a field labelled "Username", which used to bounce as a misleading 401.)
  • Cold-start resilience. This tier spins down when idle, so the first Mongo read after a wake-up can blow the driver's server-selection budget. Login and token-refresh retry a transient connection error internally; if it still fails they return 503 ("service is waking up") rather than a misleading 401, so a cold start is never mistaken for a wrong password and a transient blip never logs the client out.
  • HS256 JWTs signed with JWT_SIGNING_KEY — the same key the Modal inference service uses to verify them. Django signs; Modal verifies.
  • Custom auth class (users/authentication.py) replaces rest_framework_simplejwt + the SQL auth_user table. It decodes the token, looks up the Mongo User by sub, and attaches it as request.user.
  • No sessions, no CSRF. Auth travels in the header. CsrfViewMiddleware is intentionally omitted (silenced via SILENCED_SYSTEM_CHECKS).

Passkeys (WebAuthn / FIDO2)

Passwordless sign-in is layered on top of the same JWT scheme — a verified passkey assertion mints the identical (access, refresh) pair as a password login, so everything downstream (MongoJWTAuthentication, Modal) is unchanged.

  • Two-step ceremonies (begincomplete) for both registration and login, implemented in users/passkey_views.py. The server-issued challenge is persisted as a single-use WebAuthnChallenge and referenced by an opaque flowId, so the handshake is stateless across serverless instances.
  • Verification (attestation + assertion, COSE keys, signature counter) is delegated to the audited py_webauthn; the login begin response never discloses whether a username exists.
  • Multiple passkeys per user, managed via GET/PATCH/DELETE routes with strict per-user ownership checks.
  • RP config is env-drivenWEBAUTHN_RP_ID, WEBAUTHN_RP_NAME, WEBAUTHN_EXPECTED_ORIGINS, WEBAUTHN_CHALLENGE_TTL_SECONDS. The RP id / origins must be the frontend domain, since WebAuthn binds a passkey to the page's origin (not this API's host).

Throttling + cost protection

Django sits in front of MongoDB Atlas (cheap) and the Modal inference service (the expensive bit). Two layers of throttling protect the Modal budget end-to-end:

flowchart LR
    C[Client] -->|Bearer JWT| D[Django on Vercel]
    D -- "DRF throttle<br/>60/min anon · 240/min user" --> P{proxy?}
    P -- "yes (text / recs)" --> M[Modal inference]
    P -- "no (media direct)" --> M
    C -.->|direct upload<br/>speech / facial| M
    M -- "sliding-window<br/>45/min general · 15/min media" --> Models[(Models)]

    style D fill:#3b82f6,stroke:#fff,color:#fff
    style M fill:#7c3aed,stroke:#fff,color:#fff
Loading
Layer Where Default What it protects
DRF throttling (AnonRateThrottle, UserRateThrottle) Django, every endpoint 60/min anon, 240/min user DB load, login brute-force, the proxied inference paths
Modal sliding-window limit Modal, per inference endpoint 45/min general, 15/min media (per user JWT sub) Modal compute spend
MAX_CONTAINERS=5 Modal app config hard cap Final cost ceiling — see modal_inference/README.md

Tune the DRF tier via THROTTLE_ANON / THROTTLE_USER; tune the Modal tier via RATE_LIMIT_PER_USER / RATE_LIMIT_MEDIA_PER_USER in the Modal Secret. Service-token (Django → Modal proxy) calls bypass the Modal limiter entirely — DRF is the right place to throttle proxied traffic, so we don't double-limit a single user via two different counters.

For the full caching + rate-limit design (algorithms, multi-container trade-offs, observability), see ../modal_inference/README.md.


SRE metrics

A custom Django middleware (observability.middleware.MetricsMiddleware) records one row per request to a MongoDB Atlas time-series collection (backend_metrics). The aggregated view is queryable via GET /api/metrics/.

Schema (one doc per request)

{
  "ts":         "2026-05-23T14:30:00.123Z",
  "meta": {
    "service":      "django",
    "endpoint":     "/users/<str:user_id>/profile/",
    "method":       "GET",
    "container":    "iad1-...",
    "status_class": "2xx"
  },
  "status":     200,
  "latency_ms": 18.7
}
  • The endpoint is the URL pattern (/users/<str:user_id>/...), not the resolved path -- a million distinct user IDs collapse to one bucket so the time-series stays sane.
  • TTL: 30 days native (env-tunable). No cron required.
  • Internal paths skipped: /swagger/, /redoc/, /favicon.ico, /api/health/, /api/metrics/ itself -- liveness probes would otherwise drown signal in noise.

Reading: GET /api/metrics/?window=1h

Admin-only: requires Authorization: Bearer <ADMIN_METRICS_TOKEN> (falls back to MODAL_SERVICE_TOKEN if the dedicated admin secret isn't set). End-user JWTs are explicitly rejected -- the endpoint is listed in Swagger under the System tag.

curl -s -H "Authorization: Bearer $ADMIN_METRICS_TOKEN" \
     "https://<YOUR_URL>/api/metrics/?window=1h" | jq

window{5m, 15m, 1h, 6h, 24h, 7d, 30d} (default 1h). endpoint= optionally narrows to one URL pattern.

Response shape (identical to the Modal /metrics shape, just service: "django"):

{
  "service": "django",
  "window": {"label": "1h", "since": "...", "until": "...", "seconds": 3600},
  "persisted": {
    "available": true,
    "endpoints": [
      {
        "endpoint": "/users/login/", "method": "POST",
        "count": 412, "error_count": 3, "error_rate": 0.0073,
        "latency_ms": {"p50": 12, "p95": 31, "p99": 78, "max": 142, "mean": 18, "samples": 412},
        "status_codes": {"200": 409, "401": 3}
      }
    ]
  },
  "live": { "container": "...", "uptime_seconds": 412.5, "endpoints": [...] }
}

Configuration

METRICS_ENABLED        # default: True
METRICS_COLLECTION     # default: backend_metrics
METRICS_TTL_DAYS       # default: 30
ADMIN_METRICS_TOKEN    # admin bearer for /api/metrics/; falls back to MODAL_SERVICE_TOKEN

Resilience

The middleware is fully defensive -- a Mongo outage, a recorder bug, or a stats failure cannot break a request. All exceptions are caught locally and logged at WARNING. See observability/middleware.py.

For the matching Modal-side design, see ../modal_inference/README.md#sre-metrics.


Endpoint reference

Auth + account management — /users/

Method Path Auth Body Effect
POST /users/register/ none {username, email, password} Creates User + empty UserProfile.
POST /users/login/ none {username, password} username accepts the username or email. Returns {access, refresh}; 503 if Mongo is still cold after internal retries.
POST /users/token/refresh/ none {refresh} Returns a fresh access token. 503 if Mongo is still cold after internal retries.
GET /users/validate_token/ bearer 200 if the access token is valid.
POST /users/verify-username-email/ none {username, email} First step of forgot-password (proves identity).
POST /users/reset-password/ none {username, new_password} Second step; resets password.
GET /users/user/profile/ bearer Returns the signed-in user's profile.
PUT /users/user/profile/update/ bearer {email} Updates mutable profile fields.
DELETE /users/user/profile/delete/ bearer Permanently deletes the account + profile.

Passkeys (WebAuthn) — /users/passkeys/

Each ceremony is two calls; begin returns {options, flowId}, complete posts the signed credential back. A verified login assertion returns {access, refresh, username}.

Method Path Auth Body Effect
POST /users/passkeys/register/begin/ bearer Issues creation options (existing passkeys in excludeCredentials).
POST /users/passkeys/register/complete/ bearer {flowId, credential, name?} Verifies attestation; stores the passkey. 201.
POST /users/passkeys/login/begin/ none {username?} Issues request options (usernameless when username omitted).
POST /users/passkeys/login/complete/ none {flowId, credential} Verifies assertion; returns a JWT pair.
GET /users/passkeys/ bearer Lists the signed-in user's passkeys.
PATCH /users/passkeys/<id>/ bearer {name} Renames a passkey (owner only).
DELETE /users/passkeys/<id>/ bearer Deletes a passkey (owner only).

History — /users/{kind}/<user_id>/

Kind Methods Body
mood_history GET, POST (append), DELETE (single entry) {mood}
listening_history GET, POST (append), DELETE (single entry) {track}
recommendations GET, POST (append), DELETE (clear all) {recommendations: [...]}

Inference proxy + system — /api/

Method Path Auth Body What it does
GET /api/health/ none {status: ok}.
GET /api/metrics/ service token Aggregated p50/p95/p99, error rate, throughput per endpoint. See § SRE metrics.
POST /api/text_emotion/ optional JWT {text} Proxies to Modal /text_emotion. If the caller is authenticated, applies the per-user mood-calibration map (api/calibration.py) and may rewrite the predicted label; a calibrated_from field is added to the response in that case.
POST /api/music_recommendation/ optional JWT {emotion, market?, history?, genre?} Proxies to Modal /music_recommendation. If the caller is authenticated and warm (≥ 20 feedback events), re-ranks the result list via the Thompson-Sampling bandit (api/bandit.py). Anonymous / cold callers see the base order.
POST /api/feedback/ JWT required {kind: "mood" | "track", ...} Unified RL feedback intake. Mood payloads append to mood_feedback and bump UserProfile.mood_calibration[predicted][actual]. Track payloads (signallike / unlike / open_deezer / clear) append to track_feedback and, when the optional track dict is supplied, update UserProfile.taste_profile with set-vote semantics (see below). Returns 202 on success.
GET /api/feedback/tracks/ JWT required ?ids=<id1,id2,...> Read-back of the caller's current explicit vote per track, so the UI can restore like/dislike button state after a reload. Returns {"feedback": {track_id: "like" | "unlike"}}. A track whose latest vote is clear is omitted; the implicit open_deezer signal is never reported. Batch capped at 200 ids.

Authenticated browser / mobile clients hit the Django paths above so the RL layer can act; anonymous callers and large media uploads (speech, facial) go straight to Modal as before.

Set-vote semantics (track feedback). A track's current vote is the only thing that contributes to the bandit posterior. Before recording a new like / unlike, the endpoint looks up the prior vote (and the exact feature vector it was applied with), then reconciles: switching like ↔ unlike reverts the prior contribution and applies the new one (no double-count); re-sending the same vote is idempotent; clear retracts a prior like/unlike — it is persisted (so the button state survives a reload) and reverses that vote's exact contribution to the posterior (bandit.revert_posterior, which subtracts the stored vector and is clamped at the Beta(1,1) prior floor with events ≥ 0). open_deezer is a purely additive implicit positive signal — never a vote, so it is never reverted.

Docs

Path Body
/ 302 to /swagger/.
/swagger/ Swagger UI (CDN-loaded, see backend/swagger.py).
/redoc/ Redoc UI (CDN-loaded).
/swagger.json & /swagger.yaml OpenAPI schema.
/favicon.ico 302 to a music-note SVG on jsDelivr.

Project layout

backend/
├── manage.py
├── vercel_wsgi.py          @vercel/python entrypoint (uses backend.wsgi:application)
├── vercel.json             routes everything to vercel_wsgi.py
├── requirements.txt        slim deps -- NO ML packages
├── backend/
│   ├── settings.py         JWT + MongoDB + CORS + drf-yasg
│   ├── urls.py             /, /users/, /api/, /swagger/, /redoc/, /favicon.ico
│   ├── swagger.py          CDN-loaded Swagger + Redoc + schema endpoints
│   └── wsgi.py             standard Django WSGI application
├── api/
│   ├── views.py            health, text_emotion proxy, music_recommendation proxy
│   ├── urls.py
│   ├── models.py           UserProfile (mood/listening/recommendations history)
│   └── services/
│       └── inference_client.py    HTTP client to Modal, with retry
├── users/
│   ├── views.py            register, login, refresh, profile, password-reset, history
│   ├── passkey_views.py    WebAuthn passkey ceremonies + management (py_webauthn)
│   ├── urls.py
│   ├── authentication.py   MongoJWTAuthentication (replaces SQL auth)
│   ├── documents.py        User (Mongo, with PBKDF2 hashing)
│   └── tokens.py           jwt encode/decode wrappers
├── observability/
│   ├── recorder.py         In-process counters + ring-buffer reservoir
│   ├── store.py            Mongo time-series persistence (backend_metrics)
│   ├── middleware.py       Per-request timing + insert
│   └── views.py            GET /api/metrics/ (admin-only)
├── tests/                  221 tests, runs against mongomock
└── .env.example

Environment variables

Copy .env.examplebackend/.env for local dev; set the same names in your Vercel project's Environment Variables for production.

Variable Required Purpose
SECRET_KEY yes Django secret (use openssl rand -hex 32)
DEBUG no Default False; do not set true in prod
ALLOWED_HOSTS no Default *. .vercel.app,localhost,127.0.0.1 is reasonable
MONGO_DB_URI yes Atlas connection string (mongodb+srv://...)
MONGO_DB_NAME no Default emotion_based_music_db
MONGO_DB_USERNAME / MONGO_DB_PASSWORD sometimes Only if not embedded in the URI
MONGO_MAX_POOL_SIZE no Default 10; small for serverless
JWT_SIGNING_KEY yes Must match Modal's JWT_SIGNING_KEY
JWT_ACCESS_TOKEN_DAYS no Default 7
JWT_REFRESH_TOKEN_DAYS no Default 14
WEBAUTHN_RP_ID no Passkey Relying-Party id = the frontend bare domain (no scheme/port). Default localhost. Set to e.g. moodify-app.vercel.app in prod.
WEBAUTHN_RP_NAME no Label in the OS passkey sheet. Default Moodify.
WEBAUTHN_EXPECTED_ORIGINS no Comma-separated full frontend origins allowed to finish a ceremony. Default http://localhost:3000,http://localhost:3001.
WEBAUTHN_CHALLENGE_TTL_SECONDS no Lifetime of an in-flight ceremony challenge. Default 300.
MODAL_INFERENCE_URL yes URL printed by modal deploy modal_app.py
MODAL_SERVICE_TOKEN yes Must match Modal's MODAL_SERVICE_TOKEN
CORS_ALLOW_ALL_ORIGINS no Default True; flip to lock down
CORS_ALLOWED_ORIGINS no When the above is False
THROTTLE_ANON no DRF anonymous-user rate. Default 60/min.
THROTTLE_USER no DRF authenticated-user rate. Default 240/min.
METRICS_ENABLED no Toggle SRE metrics persistence. Default True.
METRICS_COLLECTION no Time-series collection name. Default backend_metrics.
METRICS_TTL_DAYS no Native TTL for raw metric events. Default 30.
ADMIN_METRICS_TOKEN no Bearer token that unlocks GET /api/metrics/. Falls back to MODAL_SERVICE_TOKEN so a single secret unlocks both /metrics surfaces.
CACHE_REDIS_URL no If set, use Redis (e.g. Upstash) instead of LocMem cache
SENTRY_DSN no Enable Sentry error + performance monitoring. Empty ⇒ SDK never initialises (local/CI stay offline). DSN from the unc-a4/moodify-app project.
SENTRY_ENVIRONMENT no Deploy-stage label on events. Default production (or development when DEBUG=True).
SENTRY_RELEASE no Release/version tag for regression tracking. Auto-detected from git if unset.
SENTRY_TRACES_SAMPLE_RATE no Fraction (0.0–1.0) of requests traced for performance. Default 0.1.
SENTRY_SEND_PII no Attach user id / IP / cookies to events. Default False (privacy-preserving).

Running locally

cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt

cp .env.example .env                # fill in MONGO_DB_URI, JWT_SIGNING_KEY, ...
python manage.py runserver

# Sanity:
curl http://127.0.0.1:8000/api/health/        # {"status":"ok"}
curl -I http://127.0.0.1:8000/                # 302 -> /swagger/
open http://127.0.0.1:8000/swagger/           # CDN-loaded Swagger UI

No migrate step — there's no SQL database. The first request that needs the Mongo User collection will simply hit Atlas.


Testing

pip install -r requirements.txt
pytest -q                                     # 221 tests, runs against mongomock

The whole suite is offline — conftest.py swaps the live mongoengine connection for a mongomock.MongoClient, so no real Atlas cluster is needed.

File Tests Covers
test_api_views.py 12 /api/health/, text-emotion proxy, music-recommendation proxy (including history cap + sanitization)
test_auth_endpoints.py 17 register, login, refresh, validate, forgot-password (verify + reset)
test_history_endpoints.py 14 mood / listening / recommendations CRUD
test_inference_client.py 8 HTTP client to Modal, retry behaviour, missing-URL guard
test_profile_endpoints.py 5 profile read, email update, account delete
test_functional_journey.py 1 end-to-end: register → login → analyse → save → fetch
test_users.py 23 document-level checks for User + UserProfile

OpenAPI / Swagger / Redoc

backend/swagger.py returns hand-rolled HTML for the UI pages with Swagger UI / Redoc loaded from jsDelivr. That sidesteps Django's staticfiles pipeline entirely — the docs render correctly on Vercel without ever running collectstatic. The OpenAPI schema itself is still produced by drf_yasg and served at /swagger.json and /swagger.yaml.

A music-note SVG favicon (Twemoji on jsDelivr) is referenced by both docs pages and served from /favicon.ico so browser tabs and request logs stop showing 404s.


Deployment (Vercel)

cd backend
vercel link                                   # name the project, e.g. moodify-api

# Set every required env var. CLI prompts for the value of each.
for v in SECRET_KEY DEBUG ALLOWED_HOSTS \
         MONGO_DB_URI MONGO_DB_NAME MONGO_DB_USERNAME MONGO_DB_PASSWORD \
         JWT_SIGNING_KEY MODAL_INFERENCE_URL MODAL_SERVICE_TOKEN; do
    vercel env add "$v" production
done

vercel --prod

vercel.json routes every request to vercel_wsgi.py, which the @vercel/python runtime detects and serves. The staticfiles/ directory is not generated at build time — WhiteNoise serves from the source tree via WHITENOISE_USE_FINDERS = True, and the doc pages load assets from a CDN so there's nothing to collect anyway.

After deploy:

curl https://<YOUR_DJANGO_VERCEL_URL>/api/health/           # {"status":"ok"}
curl -I https://<YOUR_DJANGO_VERCEL_URL>/                   # 302 -> /swagger/
open https://<YOUR_DJANGO_VERCEL_URL>/swagger/

Resilience + serverless gotchas

Gotcha Why it bites on serverless Fix in this codebase
pkg_resources missing on Python 3.12 drf_yasg still imports pkg_resources at module load setuptools<81 pinned in requirements.txt
Index conflicts on first request Every cold start would call ensure_indexes(), which 500s if Atlas already has a conflicting / dirty spec auto_create_index=False on both documents; indexes managed in Atlas
Mongo connection storms Each warm pod opens its own pool; many pods × big pool = Atlas connection limit hit MONGO_MAX_POOL_SIZE=10
staticfiles/ doesn't exist Vercel build doesn't run collectstatic WhiteNoise USE_FINDERS=True; docs load from CDN
No SQL Django insists on SQL by default DATABASES = {} (Django 5 allows this)
CSRF Browsers don't send a CSRF token to a JWT API CsrfViewMiddleware omitted, security.W003 silenced

Troubleshooting

Symptom Likely cause Fix
500 on every request after deploy pkg_resources missing (Python 3.12) Confirm setuptools<81 in requirements.txt
500 on first login after deploy Atlas has a stale unique index from an older schema Re-set indexes in Atlas, or just drop the affected collection (start fresh)
401 on every authenticated call from clients JWT_SIGNING_KEY mismatch between Django and Modal Set both to the same value, redeploy both
/swagger/ renders blank Wrong code path — make sure you're on the CDN-loaded swagger.py git pull, redeploy
Cold start > 5 s Mongo connection cold + lambda init Acceptable; warms up after one request
INFRA: connection limit from Atlas Too many warm pods × pool size Lower MONGO_MAX_POOL_SIZE, or bump the Atlas tier

FAQ

Why MongoDB instead of Postgres? Atlas's free tier is generous, the data model is a few document types with very list-shaped relations (history arrays inside profiles), and Vercel's serverless model fits mongoengine's stateless usage well. There's no relational join in the app's hot paths.

Why not put inference behind Django? Two reasons: (1) Vercel functions time out before a 12 MB audio file finishes uploading, and (2) Modal's GPU access and memory snapshots are not available through a Django bundle. The current split — clients call Modal directly with their JWT — avoids both ceilings.

Why no admin app in production? No SQL → no Django admin on Vercel. All app state is per-user history that lives in MongoDB Atlas and is managed through the REST API. The admin is opt-in for local dev only:

export ENABLE_ADMIN=True   # auto-on whenever DEBUG=True
python manage.py migrate   # one-time SQLite + auth tables
python manage.py createsuperuser
python manage.py runserver # http://127.0.0.1:8000/admin/

ENABLE_ADMIN=False (the default in production) leaves django.contrib.admin / auth.User table / sessions / csrf entirely out of INSTALLED_APPS, so /admin/ returns 404 and the deploy matches the original no-SQL footprint exactly.

Can I run this against a local MongoDB? Yes — set MONGO_DB_URI=mongodb://localhost:27017/emotion_based_music_db and remove ssl=True from settings.py if your local instance is plain. The test suite uses mongomock anyway, so this only matters for manual smoke-testing.


Part of the Moodify monorepo. Inference: ../modal_inference/README.md. Web client: ../frontend/README.md. Mobile client: ../mobile/README.md. Full architecture: ../ARCHITECTURE.md.