Live app: https://gasreading-backend.azurewebsites.net
This is a personal learning project built to explore full-stack development with Django, React, and Azure cloud services.
A web application for residents to submit monthly gas meter photos and for admins to review and record the readings.
- Residents log in via email OTP, upload a photo of their gas meter, and track their submission status
- Admins review submissions, enter the reading value, and mark them as reviewed
- Meter photos are stored in Azure Blob Storage
- OTP emails are sent via Azure Communication Services
| Layer | Technology |
|---|---|
| Frontend | React + Vite + Tailwind CSS |
| Backend | Django 6 + Django REST Framework |
| Auth | JWT (SimpleJWT) + email OTP |
| Database | Azure PostgreSQL Flexible Server |
| File storage | Azure Blob Storage |
| Azure Communication Services | |
| Secrets | Azure Key Vault (Managed Identity) |
| Hosting | Azure App Service (Linux) |
| CI/CD | GitHub Actions |
GasReadingApp/
├── frontend/ React app (Vite)
│ ├── src/
│ │ ├── pages/
│ │ │ ├── LoginPage.jsx OTP login
│ │ │ ├── SetupPage.jsx Profile setup (block/flat)
│ │ │ ├── UploadPage.jsx Resident submission page
│ │ │ ├── AdminPage.jsx Admin review dashboard
│ │ │ └── ProtectedRoute.jsx
│ │ ├── context/ Auth context
│ │ └── services/ API client
│ └── public/
│ └── favicon.svg
├── backend/ Django app
│ ├── config/ Settings, URLs, WSGI
│ ├── authentication/ OTP auth, JWT, user model
│ ├── submissions/ Submission model, views, serializers
│ └── requirements.txt
├── .github/
│ └── workflows/
│ └── deploy-backend.yml CI/CD pipeline
├── scripts/
│ └── deploy.ps1 Manual deploy script (PowerShell)
└── DEPLOYMENT.md Detailed deployment guide
- Python 3.12
- Node.js 20
- Azure CLI (
az loginfor OTP emails and Blob uploads) - Access to the Azure PostgreSQL firewall (your IP must be allowlisted)
cd backend
python -m venv venv
# Windows:
.\venv\Scripts\activate
# Linux/Mac:
source venv/bin/activate
pip install -r requirements.txtCreate a .env file in backend/:
SECRET_KEY=your-local-secret-key
DEBUG=True
DATABASE_URL=postgres://username:password@gasreading-db-v2.postgres.database.azure.com:5432/postgres?sslmode=require
AZURE_KEYVAULT_URL=https://gasreading-kv.vault.azure.net/
AZURE_STORAGE_ACCOUNT_NAME=gasreadingdk2024
AZURE_BLOB_CONTAINER_NAME=meter-images
AZURE_COMMUNICATION_ENDPOINT=https://gasreading-acs.unitedstates.communication.azure.com
EMAIL_SENDER_ADDRESS=DoNotReply@<your-acs-domain>
ALLOWED_HOSTS=localhost,127.0.0.1python manage.py migrate
python manage.py runservercd frontend
npm install
npm run dev # http://localhost:5173The frontend expects the backend at http://localhost:8000/api by default.
Pushes to master automatically deploy via GitHub Actions (see .github/workflows/deploy-backend.yml).
The pipeline:
- Builds the React frontend (
npm run build) - Copies the build into
backend/frontend_build/ - Runs
collectstatic(bundles React + Django admin assets) - Zips the backend and deploys to Azure App Service via Kudu
- Uploads
staticfiles/directly via Kudu VFS (ensures WhiteNoise has all files) - Restarts the app
- Hits
/health/to confirm the deploy succeeded
For manual deployment or more detail, see DEPLOYMENT.md.
| Method | Path | Access | Description |
|---|---|---|---|
| POST | /api/auth/request-otp/ |
Public | Send OTP to email |
| POST | /api/auth/verify-otp/ |
Public | Verify OTP, get JWT |
| GET/PATCH | /api/auth/profile/ |
Resident | Get/update block & flat |
| POST | /api/submissions/ |
Resident | Upload meter photo |
| GET | /api/submissions/ |
Resident | List own submissions |
| GET | /api/submissions/admin/ |
Admin | List all submissions |
| PATCH | /api/submissions/admin/<id>/ |
Admin | Review a submission |
| GET | /health/ |
Public | Health check (DB connectivity) |
| Resource | Name |
|---|---|
| App Service | gasreading-backend |
| Resource Group | gas-reading-app-rg |
| PostgreSQL | gasreading-db-v2 |
| Key Vault | gasreading-kv |
| Blob Storage | gasreadingdk2024 / meter-images |
| Communication Services | gasreading-acs |
- Django and React can be served from a single App Service — WhiteNoise serves static files and the SPA catch-all handles client-side routing
WHITENOISE_ROOT = STATIC_ROOTis needed to serveindex.htmlat/(not just/static/index.html)gunicornneeds--chdir /home/site/wwwroototherwise it cannot findconfig.wsgias a Python module- Never set
PYTHONPATHto a Windows path in App Service settings — it will crash on Linux
- Vite's default
base: '/'outputs<script src="/assets/index.js">— Django serves static files at/static/, so the mismatch causes JS/CSS to returntext/htmlMIME type and a blank page - The fix is
base: '/static/'invite.config.js— always match Vite's base to Django'sSTATIC_URL - WhiteNoise indexes static files once at startup — files uploaded after the app starts are not served until restart
az webapp deploy --type zipdoes NOT trigger Oryx (pip install). Always useaz webapp deployment source config-zip(Kudu zipdeploy)Compress-Archiveon Windows creates zip entries with backslash paths (config\urls.py) — Linux treats these as a single filename, so files in subdirectories are silently not updated on redeploy. Fix: use Linuxzipin CI/CD- Uploading staticfiles via Kudu VFS before restarting ensures WhiteNoise has all files indexed at startup
DefaultAzureCredentialprobes 7+ credential providers sequentially — each times out locally, adding 10+ seconds. UseAzureCliCredentialexplicitly for local dev andManagedIdentityCredentialon App Service- Azure Key Vault with Managed Identity means zero secrets in App Service config or environment — the app fetches them at startup
- Git Bash on Windows converts
/subscriptions/...paths to Windows paths when passed to Azure CLI. Fix: setMSYS_NO_PATHCONV=1
- A single pipeline can build frontend (Node) and backend (Python), bundle them, and deploy — keeping everything in sync on every push
- GitHub Actions secrets (
AZURE_CREDENTIALS) store the service principal JSON foraz login - A health check step at the end of the pipeline catches deploy failures before they reach users
- OTP codes should use
secrets.choice(cryptographically secure RNG), notrandom.choices - JWT tokens for stateless auth — no server-side session storage needed
- All database credentials and API keys in Key Vault, accessed via Managed Identity — no hardcoded secrets anywhere
Resident — any authenticated user with a complete profile (block + flat set)
- Can submit one meter photo per period
- Can view their own submission history and status
Admin — users with is_staff=True in Django
- Can view all submissions with filtering and sorting
- Can enter reading values and mark submissions as reviewed
- Access Django admin at
/admin/