Django REST API for a city apartment booking platform. Property owners list spaces, guests browse and book. Built with Django 5.x, DRF, dual-layer auth (API Key + JWT), and deployed free on Hugging Face Spaces backed by Neon PostgreSQL and Cloudflare R2.
- What This Project Is
- Tech Stack
- Architecture
- Project Structure
- Three Environments
- Security Model
- CI Pipeline
- Git Branching Strategy
- Local Development Setup
- Staging Deployment
- Production Deployment
- API Documentation
- Testing with Bruno
- Feature Build Order
- Daily Commands
- Known Pitfalls
- Naming Conventions
City Stays is a property booking platform — think Airbnb for city apartments. This repository is the backend API only. It is a Django application that:
- Receives requests from the React frontend over HTTPS
- Validates authentication (API Key + JWT token)
- Reads and writes data to PostgreSQL
- Returns structured JSON responses
The React frontend lives in a separate repository (city-stays-booking). The two services communicate exclusively over HTTP — they share no code.
| Decision | Rationale |
|---|---|
| uv over pip | 10–100× faster installs. One tool replaces pip, venv, and pyenv. |
| Ruff over flake8 + black + isort | One tool, one config block. 100× faster. The 2026 Python standard. |
| Neon over a VPS database | Serverless PostgreSQL — scales to zero, never pay for idle compute. |
| Hugging Face Spaces over Heroku | Completely free Docker hosting. No credit card, ever. |
| Cloudflare R2 over S3 | Zero egress fees. S3-compatible API. Globally fast CDN. |
| Bruno over Postman | Collections are plain files committed to the repo — Git-native. |
APPEND_SLASH = False |
REST URLs are contracts. No silent 301 redirects before DRF runs. |
| Layer | Technology | Version | Purpose |
|---|---|---|---|
| Language | Python | 3.14 | Runtime |
| Framework | Django | 5.x | Routing, ORM, Admin |
| REST layer | Django REST Framework | 3.16.0 | JSON API on top of Django |
| API Key Auth | djangorestframework-api-key | 3.0.0 | Layer 1 — per-client identity |
| JWT Auth | djangorestframework-simplejwt | 5.5.0 | Layer 2 — per-user identity |
| API Docs | drf-spectacular | 0.28.0 | Auto-generates Swagger UI + ReDoc |
| DB Adapter | psycopg3 | ≥3.2.4 | Django → PostgreSQL connector |
| Package Manager | uv | 0.7.x+ | Installs packages, manages venv |
| Static Files (local) | whitenoise | 6.8.2 | Serves CSS/JS from Django locally |
| Static/Media (cloud) | Cloudflare R2 | — | S3-compatible, zero egress fees |
| Config | django-environ | 0.11.2 | Reads .env files safely |
| Linter + Formatter | Ruff | 0.9.0+ | Replaces flake8 + black + isort |
| Test Runner | pytest-django | ≥4.9.0 | pytest with Django integration |
| Coverage | pytest-cov | ≥6.0.0 | Line + branch coverage reporting |
| DB (local) | PostgreSQL | 18.3 | Windows service on dev machine |
| DB (cloud) | Neon Serverless PostgreSQL | — | Separate branch per environment |
| Hosting | Hugging Face Spaces | — | Free Docker-based hosting |
| API Testing | Bruno | latest | Git-native request collections |
graph TB
CLIENT["🖥️ React Frontend<br>city-stays-booking.netlify.app"]
CF_CDN["☁️ Cloudflare CDN<br>Static & Media Files<br>R2 Object Storage"]
HF["🤗 Hugging Face Spaces<br>Django REST API · Docker<br>city-stays-booking-api.hf.space<br>Port 7860"]
NEON["🐘 Neon Serverless PostgreSQL<br>Main Branch · Scales to Zero<br>Pooled SSL Connection"]
CLIENT -->|"HTTPS<br>X-Api-Key + Bearer JWT"| HF
CLIENT -->|"Static & media files<br>direct from CDN"| CF_CDN
HF -->|"collectstatic on build<br>boto3 / django-storages"| CF_CDN
HF -->|"conn_max_age=0 · SSL · psycopg3"| NEON
graph LR
subgraph LOCAL["💻 LOCAL — Windows 10"]
L1["Django Dev Server<br>127.0.0.1:8000"]
L2["PostgreSQL 18.3<br>Windows Service · port 5432"]
L3["Local Filesystem<br>staticfiles/ media/"]
L4["Swagger UI /api/docs/<br>Open — no auth needed"]
L1 --- L2
L1 --- L3
L1 --- L4
end
subgraph STAGING["🔵 STAGING — HF Spaces + Neon + R2"]
S1["Django App<br>Port 7860 · staging Space"]
S2["Neon PostgreSQL<br>Staging Branch"]
S3["Cloudflare R2<br>city-stays-staging bucket"]
S4["Swagger UI /api/docs/<br>API Key required"]
S1 --- S2
S1 --- S3
S1 --- S4
end
subgraph PROD["🔴 PRODUCTION — HF Spaces + Neon + R2"]
P1["Django App<br>Port 7860 · production Space"]
P2["Neon PostgreSQL<br>Main Branch"]
P3["Cloudflare R2<br>city-stays-production bucket"]
P4["Swagger UI<br>DISABLED — 404"]
P1 --- P2
P1 --- P3
P1 --- P4
end
LOCAL -->|"git push develop → CI → deploy"| STAGING
STAGING -->|"git push main → CI → deploy"| PROD
flowchart TD
USER["User clicks Book Now in React app"]
REACT["React sends POST /api/bookings/<br>Headers: X-Api-Key + Authorization: Bearer token"]
HF["Hugging Face Spaces<br>receives the HTTPS request"]
LAYER1{"Layer 1 Check<br>Is X-Api-Key valid?"}
LAYER2{"Layer 2 Check<br>Is JWT Bearer token valid?"}
VIEW["Django view runs<br>Reads/writes PostgreSQL<br>Returns JSON response"]
REACT2["React receives JSON<br>Updates the UI"]
ERR1["403 Forbidden<br>Wrong or missing API key"]
ERR2["401 Unauthorized<br>Not logged in or token expired"]
USER --> REACT
REACT --> HF
HF --> LAYER1
LAYER1 -->|"No"| ERR1
LAYER1 -->|"Yes"| LAYER2
LAYER2 -->|"No"| ERR2
LAYER2 -->|"Yes"| VIEW
VIEW --> REACT2
city-stays-booking-backend/
│
├── .github/
│ └── workflows/
│ └── ci.yml ← 5-job CI pipeline — runs on every push
│
├── .husky/
│ ├── pre-commit ← Ruff lint + format on staged files
│ ├── pre-push ← Full pytest suite before every push
│ └── commit-msg ← Validates Conventional Commits format
│
├── config/ ← Django project config (not an app)
│ ├── __init__.py
│ ├── settings.py ← All settings — reads from env vars
│ ├── urls.py ← Root URL router
│ ├── asgi.py ← ASGI entry point
│ └── wsgi.py ← WSGI entry point — used by Gunicorn
│
├── core/ ← First Django app — foundational code
│ ├── migrations/
│ │ └── __init__.py
│ ├── __init__.py
│ ├── admin.py ← Model registration for /admin
│ ├── apps.py ← App config
│ ├── models.py ← DB table definitions
│ ├── views.py ← HTTP request handlers
│ ├── urls.py ← URL patterns for core app
│ └── tests.py ← pytest test suite (uses APIClient)
│
├── bruno/ ← API test collections (Git-committed)
│ ├── environments/
│ │ ├── local.bru
│ │ ├── staging.bru
│ │ └── production.bru
│ └── health/
│ └── hello-world.bru
│
├── .env ← ⚠️ LOCAL ONLY — NEVER commit
├── .gitignore
├── .python-version ← Pins Python 3.14 for uv
├── commitlint.config.toml ← Conventional Commits rules
├── Dockerfile ← For Hugging Face Spaces (port 7860)
├── manage.py ← Django CLI tool
├── pyproject.toml ← All tool config: deps, Ruff, pytest, coverage
├── uv.lock ← Exact pinned versions — always commit this
└── start.cmd ← Windows shortcut to start dev server
config/settings.py — The brain of the application. All configuration reads from environment variables via django-environ. APPEND_SLASH = False prevents CommonMiddleware from issuing 301 redirects that break API clients. JWT lifetimes, DB connection pooling, and security headers are all environment-aware.
config/urls.py — The receptionist. Swagger/ReDoc URLs are only registered when DJANGO_ENV != "production" — the schema is never exposed publicly.
core/tests.py — Uses DRF's APIClient (not Django's Client) so Accept: application/json is sent automatically on every request. This prevents content negotiation from falling back to HTML.
pyproject.toml — Single source of truth for all tooling: dependencies, Ruff rules, pytest config, and coverage thresholds. If a tool behaves differently from what you expect, look here first.
uv.lock — Snapshot of every installed package at its exact version. Always commit this file. CI and Docker both use --frozen to guarantee byte-identical installs.
Think of environments like a workshop, a dress rehearsal, and live theatre.
| Setting | Local | Staging | Production |
|---|---|---|---|
DJANGO_ENV |
local |
staging |
production |
DEBUG |
True |
False |
False |
| Database | PostgreSQL 18.3 on Windows | Neon staging branch | Neon main branch |
| Static files | Local filesystem | Cloudflare R2 | Cloudflare R2 |
| Media files | Local filesystem | Cloudflare R2 | Cloudflare R2 |
| Port | 8000 | 7860 | 7860 |
SECRET_KEY source |
.env file |
HF Space Secret | HF Space Secret |
conn_max_age |
600s | 0 |
0 |
| Swagger UI | ✅ Open | 🔑 API Key required | ❌ Disabled (404) |
| JWT Access Token | 60 min | 15 min | 15 min |
| JWT Refresh Token | 7 days | 1 day | 1 day |
| HTTPS enforced | No | Yes | Yes |
⚠️ Whyconn_max_age = 0on Neon: Neon is serverless — it scales to zero when idle. A persistent connection (conn_max_age > 0) goes stale while Neon sleeps. The next request fails withSSL connection has been closed unexpectedly. Setting it to0forces a fresh connection on every request. This is mandatory for all serverless databases. Never change this.
Every API request passes through two sequential security checks.
flowchart LR
REQ["Incoming Request<br>from React or Bruno"]
subgraph L1["Layer 1 — API Key — Every Request"]
AK{"X-Api-Key header<br>present and valid?"}
AK -->|"No"| R1["403 Forbidden<br>Wrong or missing API key"]
end
AK -->|"Yes"| L2
subgraph L2["Layer 2 — JWT — Protected Endpoints Only"]
JWT{"Authorization: Bearer<br>token valid and not expired?"}
JWT -->|"No"| R2["401 Unauthorized<br>Not logged in or token expired"]
end
JWT -->|"Yes"| RESP["✅ 200 — Request processed"]
REQ --> L1
Identifies which client application is making the request. Every single request to every endpoint must include this header. Without it, the server returns 403 Forbidden before any Django logic runs. This blocks bots, scrapers, and unauthorized clients at the door.
How to create an API key:
- Go to
http://127.0.0.1:8000/admin/→ log in with your superuser credentials - Navigate to API Key Permissions → API Keys → Add API Key
- Give it a name (e.g.
Local Frontend) and save - Copy the generated key immediately — it cannot be retrieved again
Identifies which user is making the request. A short-lived, cryptographically signed token that proves identity. Required only on protected endpoints (anything that needs to know who is acting).
| Header | Value | Required For |
|---|---|---|
X-Api-Key |
your-api-key |
Every endpoint |
Authorization |
Bearer eyJ... |
Protected endpoints only |
The health-check endpoint (GET /api/hello/) is intentionally public — load balancers and uptime monitors must be able to reach it without credentials. The view uses @permission_classes([AllowAny]) and @authentication_classes([]) to override the global HasAPIKey default.
Five jobs run on every push to any branch.
flowchart LR
PUSH["git push"]
LINT["Job 1: Fast Linting<br>ruff check + ruff format --check<br>Fails in under 10s"]
TYPE["Job 2: Type Checking<br>mypy placeholder<br>Stable slot in pipeline graph"]
TEST["Job 3: Integration Tests<br>pytest against real PostgreSQL 16<br>Coverage report uploaded"]
SEC["Job 4: Security & Audit<br>pip-audit scans uv.lock for CVEs"]
GATE["Job 5: CI Passed ✅<br>Aggregator — branch protection<br>targets this job only"]
PUSH --> LINT
LINT --> TYPE
LINT --> TEST
LINT --> SEC
TYPE & TEST & SEC --> GATE
Job 1 — Fast Linting: Catches undefined variables, unused imports, formatting drift, and security anti-patterns. Takes under 10 seconds. Developers get instant feedback on every push.
Job 2 — Type Checking: Placeholder for mypy/pyright. Kept in the graph so adding type checking later never requires touching branch protection rules.
Job 3 — Integration Tests: Runs against a real PostgreSQL 16 service container — not SQLite. Catches database-specific behaviour (JSON fields, SELECT FOR UPDATE, index usage). Produces coverage.xml uploaded as an artifact.
Job 4 — Security & Audit: pip-audit scans the exact locked versions from uv.lock against the CVE database. Uses --no-hashes export to avoid false-positive hash mismatches from binary wheel build dependencies.
Job 5 — CI Gate: A single aggregator job that is either green or red. GitHub branch protection targets only this job name, so adding or removing jobs 1–4 never requires touching branch protection settings.
All actions (actions/checkout@v4, astral-sh/setup-uv@v6, actions/upload-artifact@v4) target the Node.js 24 runtime, ahead of GitHub's June 2026 enforcement deadline.
gitGraph
commit id: "ci: setup project tooling"
branch develop
checkout develop
commit id: "feat: hello world ✅"
branch feature/auth
checkout feature/auth
commit id: "feat(auth): api key middleware"
commit id: "feat(auth): jwt login + refresh"
commit id: "test(auth): full auth test suite"
checkout develop
merge feature/auth id: "merge: full auth layer"
branch feature/properties
checkout feature/properties
commit id: "feat(properties): property model"
commit id: "feat(properties): list + detail API"
checkout develop
merge feature/properties
checkout main
merge develop id: "release: auth + properties"
| Branch | Purpose | Access |
|---|---|---|
feature/* |
One feature in development | Direct push |
develop |
Integration — always deployable to staging | PR only, CI must pass |
main |
Production — always live | PR from develop only, CI must pass |
flowchart LR
A["Create feature branch<br>from develop"] --> B["Write code locally"]
B --> C["Local checks pass<br>ruff + pytest + django check"]
C --> D["Push to GitHub<br>CI runs automatically"]
D --> E{"CI green?"}
E -->|"No"| B
E -->|"Yes"| F["Open PR to develop<br>Merge when ready"]
F --> G["Auto-deploy to staging<br>HF Spaces"]
G --> H["Test on staging<br>with Bruno"]
H --> I{"Staging verified?"}
I -->|"No"| B
I -->|"Yes"| J["Open PR from develop to main"]
J --> K["Auto-deploy to production"]
All commit messages follow the Conventional Commits spec. Husky enforces this on every git commit.
feat(auth): add JWT login endpoint
fix(bookings): prevent double-booking race condition
test(properties): add coverage for filter logic
ci: upgrade actions to Node.js 24
docs: update API auth section in README
refactor(core): extract booking logic to service layer
Install these tools on Windows 10 before starting:
| Tool | Download | Verify |
|---|---|---|
| Git | git-scm.com | git --version |
| uv | PowerShell (see below) | uv --version |
| PostgreSQL 18.3 | enterprisedb.com | psql --version |
| VS Code | code.visualstudio.com | Open app |
| Node.js LTS | nodejs.org | node --version |
Install uv (PowerShell as Administrator):
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"Configure Git (prevents Ruff formatting failures from CRLF line endings):
git config --global user.name "Your Full Name"
git config --global user.email "your-email@example.com"
git config --global core.autocrlf falseAdd PostgreSQL to PATH (CMD as Administrator, then open a new window):
setx PATH "%PATH%;C:\Program Files\PostgreSQL\18\bin" /M| Extension | Publisher | Purpose |
|---|---|---|
| Python | Microsoft | Language support |
| Pylance | Microsoft | Type checking |
| Ruff | Astral Software | Lint + format on save |
| Django | Baptiste Darthenay | Template + URL support |
| GitLens | GitKraken | Inline Git history |
| PostgreSQL | Chris Kolkman | Browse DB from VS Code |
| Docker | Microsoft | Container management |
flowchart TD
A(["▶ Start"]) --> B["Clone repo and create develop branch"]
B --> C["Create PostgreSQL database and user"]
C --> D["Bootstrap Django project with uv"]
D --> E["Write all config files"]
E --> F["Create .env file with local secrets"]
F --> G["Run migrations and create superuser"]
G --> H["Run all local checks"]
H --> I{"All green?"}
I -->|"No — fix the error"| H
I -->|"Yes"| J["First commit and push to GitHub"]
J --> K{"CI passes?"}
K -->|"No — fix the error"| H
K -->|"Yes"| L(["✅ Phase 1 Complete"])
cd C:\Users\YOUR_USERNAME\Documents\GitHub
git clone https://github.com/YOUR_USERNAME/city-stays-booking-backend.git
cd city-stays-booking-backend
git checkout -b develop
git push -u origin developpsql -U postgres -h 127.0.0.1CREATE USER city_stays_booking_user WITH PASSWORD 'CityStays#2026';
CREATE DATABASE city_stays_booking_db OWNER city_stays_booking_user;
GRANT ALL PRIVILEGES ON DATABASE city_stays_booking_db TO city_stays_booking_user;
ALTER USER city_stays_booking_user CREATEDB;
\quv python pin 3.14
uv add django djangorestframework djangorestframework-api-key
uv add djangorestframework-simplejwt drf-spectacular
uv add "psycopg[binary]>=3.2.4,<3.3.0" django-environ whitenoise gunicorn
uv add --dev ruff pytest-django "pytest-cov>=6.0.0"
uv run django-admin startproject config .
uv run python manage.py startapp core
⚠️ Create this file in VS Code only. Bottom-right corner must show UTF-8 and LF — never CRLF. Never use Notepad.
# .env — Local Windows 10 development — NEVER commit this file
DJANGO_ENV=local
DEBUG=True
ALLOWED_HOSTS=127.0.0.1,localhost
DATABASE_URL=postgres://city_stays_booking_user:CityStays#2026@127.0.0.1:5432/city_stays_booking_db
SECRET_KEY=replace-this-with-output-of-the-command-belowGenerate a real SECRET_KEY:
uv run python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"uv run python manage.py migrate
uv run python manage.py createsuperuser
uv run python manage.py runserver| URL | Expected Result |
|---|---|
http://127.0.0.1:8000/api/hello/ |
{"status": "ok", "message": "City Stays Booking API is running 🏙️", ...} |
http://127.0.0.1:8000/api/docs/ |
Swagger UI — full interactive schema |
http://127.0.0.1:8000/api/redoc/ |
ReDoc documentation |
http://127.0.0.1:8000/admin/ |
Django admin login |
uv run ruff check . # Lint
uv run ruff format . --check # Format
uv run python manage.py check # Django system check
uv run pytest # Full test suiteflowchart TD
A(["✅ Phase 1 Complete"]) --> B["Create Neon project + staging branch"]
B --> C["Create Cloudflare account + R2 bucket"]
C --> D["Add django-storages + boto3"]
D --> E["Create Hugging Face account + staging Space"]
E --> F["Write Dockerfile"]
F --> G["Set all Secrets in HF Space settings"]
G --> H["Run Neon migrations from local machine"]
H --> I["Push to HF Space manually — first deploy"]
I --> J["Verify staging with Bruno"]
J --> K["Add HF_TOKEN to GitHub Secrets for CI/CD"]
K --> L(["✅ Staging Live"])
- Sign up at neon.tech (free, no credit card)
- Create project:
city-stays-booking— choose region closest to your users - Neon creates a
mainbranch automatically (this will be production) - Go to Branches → New Branch — name:
staging - From the staging branch, copy both connection strings:
# Pooled URL — used by the running app
postgresql://user:pass@ep-XXXX-pooler.eu-west-2.aws.neon.tech/neondb?sslmode=require&channel_binding=require
# Direct URL — used only for running migrations
postgresql://user:pass@ep-XXXX.eu-west-2.aws.neon.tech/neondb?sslmode=require&channel_binding=require
⚠️ Always use the Direct URL formigrateand the Pooled URL for the app. Using pooled for migrations causes incomplete migrations. Using direct in the app causes SSL errors under load.
- Sign up at cloudflare.com (free)
- Go to R2 Object Storage → Create bucket
city-stays-stagingcity-stays-production
- R2 → Manage R2 API tokens → Create API token
- Permissions: Object Read & Write
- Scope: Both buckets
- Copy Access Key ID and Secret Access Key immediately
- For each bucket: Settings → Public access → Allow public access
- Sign up at huggingface.co (free, no credit card)
- Spaces → New Space
- Name:
city-stays-booking-staging - SDK: Docker ← critical
- Hardware: CPU basic (free)
- Visibility: Private
- Name:
- Go to Space Settings → Secrets and add:
| Secret Name | Value |
|---|---|
DJANGO_ENV |
staging |
DEBUG |
False |
SECRET_KEY |
your generated secret key |
ALLOWED_HOSTS |
YOUR_HF_USERNAME-city-stays-booking-staging.hf.space |
DATABASE_URL |
Neon pooled URL for staging branch |
DATABASE_URL_DIRECT |
Neon direct URL for staging branch |
CLOUDFLARE_R2_BUCKET |
city-stays-staging |
CLOUDFLARE_R2_ENDPOINT |
https://ACCOUNT_ID.r2.cloudflarestorage.com |
CLOUDFLARE_R2_ACCESS_KEY |
your R2 access key |
CLOUDFLARE_R2_SECRET_KEY |
your R2 secret key |
CLOUDFLARE_R2_PUBLIC_URL |
https://pub-XXXX.r2.dev |
# Dockerfile — Hugging Face Spaces requires port 7860
FROM python:3.14-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
libpq-dev \
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY . .
RUN uv run python manage.py collectstatic --noinput --clear
EXPOSE 7860
CMD ["uv", "run", "gunicorn", "config.wsgi:application", \
"--bind", "0.0.0.0:7860", \
"--workers", "2", \
"--timeout", "120"]Identical process to staging, with two differences:
- Create a second HF Space:
city-stays-booking-api - Set
DATABASE_URLto the Neon main branch connection strings
⚠️ Never run migrations against the production Neon main branch from your local machine during active traffic. Always schedule migrations during a maintenance window or use Neon's branching to test the migration on a copy first.
flowchart TD
REQ["Request to /api/docs/"]
ENV{"Which environment?"}
LOCAL["✅ Open access<br>No auth required<br>Full interactive schema"]
STAGING["🔑 API Key required<br>Add X-Api-Key in Swagger UI<br>Authorize dialog"]
PROD["❌ 404 Not Found<br>Docs disabled in production<br>Schema never exposed publicly"]
REQ --> ENV
ENV -->|"local"| LOCAL
ENV -->|"staging"| STAGING
ENV -->|"production"| PROD
| URL | Local | Staging | Production |
|---|---|---|---|
/api/docs/ |
✅ Open | 🔑 API Key | ❌ 404 |
/api/redoc/ |
✅ Open | 🔑 API Key | ❌ 404 |
/api/schema/ |
✅ Open | 🔑 API Key | ❌ 404 |
Docs are disabled in production because exposing the full API schema gives attackers a detailed roadmap of every endpoint, expected payload, and authentication mechanism.
Bruno stores API test collections as plain text files committed to this repository. Your whole team shares the same test collections automatically.
Collection structure:
bruno/
├── environments/
│ ├── local.bru ← http://127.0.0.1:8000
│ ├── staging.bru ← https://YOUR_HF_USERNAME-city-stays-booking-staging.hf.space
│ └── production.bru ← https://YOUR_HF_USERNAME-city-stays-booking-api.hf.space
└── health/
└── hello-world.bru
bruno/health/hello-world.bru:
meta {
name: Hello World
type: http
seq: 1
}
get {
url: {{baseUrl}}/api/hello/
body: none
auth: none
}
Download Bruno at usebruno.com. Open the bruno/ folder inside Bruno and select the environment matching your target.
flowchart LR
F1["1. Hello World<br>✅ Done"] --> F2["2. DRF + Swagger<br>Bruno setup"]
F2 --> F3["3. API Key Auth<br>all environments"]
F3 --> F4["4. JWT Auth<br>login · refresh · logout · blacklist"]
F4 --> F5["5. Custom User Model<br>email-based login"]
F5 --> F6["6. Domain Models<br>Property · Room · Booking · Guest"]
F6 --> F7["7. Property API<br>CRUD + filtering"]
F7 --> F8["8. Booking API<br>transaction.atomic()<br>availability logic"]
F8 --> F9["9. HF Deploy<br>Dockerfile + Cloudflare R2<br>staging verified"]
# 1. Create feature branch from develop
git checkout develop && git pull origin develop
git checkout -b feature/your-feature-name
# 2. Write code
# 3. Run all checks before committing
uv run ruff check .
uv run ruff format . --check
uv run python manage.py check
uv run pytest
# 4. Commit — Husky runs ruff automatically on staged files
git add .
git commit -m "feat(scope): description of what you built"
# 5. Push and let CI run
git push origin feature/your-feature-name
# 6. Open PR to develop → merge when CI is green
# 7. Test on staging with Bruno
# 8. Merge develop to main when staging is verified# ── Navigation ─────────────────────────────────────────────────────────────
cd C:\Users\YOUR_USERNAME\Documents\GitHub\city-stays-booking-backend
# ── Server ─────────────────────────────────────────────────────────────────
uv run python manage.py runserver
# ── Testing ────────────────────────────────────────────────────────────────
uv run pytest
uv run pytest --cov=. --cov-report=term-missing
uv run pytest -v -k "test_hello" # Run a specific test
# ── Linting & Formatting ───────────────────────────────────────────────────
uv run ruff check . # Lint check
uv run ruff check --fix . # Auto-fix lint issues
uv run ruff format . --check # Format check
uv run ruff format . # Auto-format all files
# ── Django Management ──────────────────────────────────────────────────────
uv run python manage.py check # Django system check (local)
uv run python manage.py check --deploy # Simulate production settings check
uv run python manage.py makemigrations
uv run python manage.py migrate
uv run python manage.py createsuperuser
uv run python manage.py shell
uv run python manage.py flushexpiredtokens # Flush expired JWT blacklist tokens
# ── Neon Staging Migration ─────────────────────────────────────────────────
set DATABASE_URL=postgresql://user:pass@ep-XXXX.eu-west-2.aws.neon.tech/neondb?sslmode=require&channel_binding=require
uv run python manage.py migrate
# ── Packages ───────────────────────────────────────────────────────────────
uv add package-name
uv add --dev package-name
# ── Docker ─────────────────────────────────────────────────────────────────
docker build -t city-stays-api .
docker run -p 7860:7860 --env-file .env city-stays-api
# ── Manual HF Deploy ───────────────────────────────────────────────────────
git push hf-staging develop:main
git push hf-prod main:main| Problem | Cause | Fix |
|---|---|---|
.env causes UnicodeDecodeError |
File saved as UTF-16 or CRLF | Create .env only in VS Code — verify UTF-8 + LF in the bottom-right corner |
psql not recognized in CMD |
PostgreSQL bin not in PATH | Run setx PATH as Administrator, then open a new CMD window |
Connection refused on port 5432 |
PostgreSQL service stopped | net start postgresql-x64-18 as Administrator |
| Ruff format check fails in CI | Files not formatted locally | Run uv run ruff format . then commit the reformatted files |
| Django admin login fails | Wrong password | Admin uses the createsuperuser password, not the PostgreSQL password |
| HF Space shows blank page | Wrong port in Dockerfile | Port must be 7860 — no exceptions |
| HF Space build fails | Missing system deps for psycopg3 | Dockerfile must have libpq-dev gcc in the apt-get install line |
| Neon SSL error | Missing SSL params in URL | All Neon URLs need ?sslmode=require&channel_binding=require |
SSL connection closed unexpectedly on Neon |
conn_max_age > 0 set |
Set CONN_MAX_AGE = 0 for all non-local environments — mandatory |
ProgrammingError: relation does not exist |
Migrations not run | CI runs manage.py migrate before pytest — check your CI logs |
| Neon migration hangs | Using pooled URL for migrate |
Always use DATABASE_URL_DIRECT (no -pooler) for migrations |
| HF Space secrets not loading | Added as Variables not Secrets | In HF Space Settings, move sensitive values from Variables to Secrets |
collectstatic fails in Docker build |
SECRET_KEY not set at build time |
Ensure all required env vars are set as HF Space Secrets |
| Swagger shows 404 in production | Intentionally disabled | Use Bruno or curl for production testing |
| Swagger shows 401 in staging | Missing API key | Add X-Api-Key in the Swagger UI Authorize dialog |
| API key cannot be retrieved | Not copied at creation | Copy immediately after creation — it cannot be seen again. Create a new key. |
HF_TOKEN CI deploy fails |
Token has read-only scope | Regenerate token with Write scope on Hugging Face |
| HF cold start slow (~10s) | Free tier spins down after idle | Expected on free tier. Upgrade hardware tier to prevent spin-down. |
| Cloudflare R2 returns 403 | Bucket not set to public access | R2 bucket settings → Public access → Allow public access |
| Static files not found after deploy | collectstatic not run |
Ensure RUN uv run python manage.py collectstatic --noinput --clear is in Dockerfile |
uv.lock not committed |
Developer forgot | Always commit uv.lock — CI and Docker both depend on it |
T201 print found in CI |
Bare print() in production code |
Use sys.stdout.write() or remove the print statement |
| Tests return 301 instead of 200 | Using django.test.Client |
Use rest_framework.test.APIClient — it sends Accept: application/json |
Tests return text/html content-type |
Missing Accept header |
APIClient sets this automatically; Django's Client does not |
pip-audit hash mismatch |
uv export --require-hashes used |
Use uv export --no-hashes — pip-audit still validates exact CVE versions |
| Context | Name | Rule |
|---|---|---|
| Git repository | city-stays-booking-backend |
Hyphens — GitHub standard |
| Python/Django internals | city_stays_booking_api |
Underscores — Python module rule |
| Django config module | config/ |
Separate from app modules |
| PostgreSQL DB (local) | city_stays_booking_db |
Underscores — SQL standard |
| PostgreSQL user (local) | city_stays_booking_user |
Underscores — SQL standard |
| Neon project | city-stays-booking |
Hyphens — Neon convention |
| HF Space (staging) | city-stays-booking-staging |
Hyphens — HF convention |
| HF Space (production) | city-stays-booking-api |
Hyphens — HF convention |
| Frontend repo | city-stays-booking |
Existing — kept separate |
| Commit messages | feat(scope): description |
Conventional Commits |
| Feature branches | feature/short-description |
Lowercase hyphens |
Phase 1 — ✅ Complete
GET /api/hello/returns200 OKwith JSON — verified locally with PostgreSQL- Full pytest suite green — 8 tests, all using
APIClient, 89% coverage - Ruff lint + format both clean
- CI pipeline fully configured — 5 jobs, all green
- Branch protection on
mainrequiring the CI gate job to pass - Husky hooks enforcing: Ruff on pre-commit, pytest on pre-push, Conventional Commits on commit-msg
Phase 2 — 🔵 Next
- Create Neon project → staging branch → copy both connection strings
- Create Cloudflare account → two R2 buckets (staging + production)
- Add
django-storages[s3]+boto3→ updatesettings.pySTORAGESblock - Create Hugging Face account → staging Space (Docker, free tier)
- Write
Dockerfile→ set all HF Space Secrets → first manual deploy - Verify staging with Bruno → run smoke test collection
- Add
HF_TOKENto GitHub Secrets → enable CI auto-deploy - Create production Space → repeat deployment for production
City Stays Booking Backend
Django 5.x · Python 3.14 · uv · Ruff · PostgreSQL
Local: PostgreSQL 18.3 · Cloud: Neon + Hugging Face Spaces + Cloudflare R2
Security: API Key (Layer 1) + JWT with rotation and blacklist (Layer 2)
Testing: Bruno (functional) · pytest (automated) · pip-audit (security)