From f14dabe760335e5de575414d7762008f42a0761f Mon Sep 17 00:00:00 2001 From: Daniel Adekugbe Date: Sat, 7 Feb 2026 22:17:44 +0000 Subject: [PATCH 01/14] Add production deployment configuration for Railway Configure Django settings for production with env-based config, security hardening (HSTS, HTTPS, secure cookies), WhiteNoise static files, dj-database-url for Railway PostgreSQL, and gunicorn server. Includes deployment docs, CI workflow, and env variable template. Co-Authored-By: Claude Opus 4.6 --- .env.example | 52 +++++ .github/workflows/README.md | 129 +++++++++++ .github/workflows/test-and-deploy.yml | 104 +++++++++ Dockerfile | 8 +- PRODUCTION_SECURITY_SUMMARY.md | 299 ++++++++++++++++++++++++++ RAILWAY_DEPLOYMENT.md | 277 ++++++++++++++++++++++++ django_project/settings.py | 83 +++++-- requirements.txt | 3 + 8 files changed, 937 insertions(+), 18 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/README.md create mode 100644 .github/workflows/test-and-deploy.yml create mode 100644 PRODUCTION_SECURITY_SUMMARY.md create mode 100644 RAILWAY_DEPLOYMENT.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..625a3c4 --- /dev/null +++ b/.env.example @@ -0,0 +1,52 @@ +# Django Backend Environment Variables +# Copy this file to .env and fill in your values + +# ========================= +# SECURITY SETTINGS +# ========================= + +# Generate a new secret key for production! +# Run: python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())' +SECRET_KEY=django-insecure-CHANGE-THIS-IN-PRODUCTION + +# Debug mode - MUST be False in production +DEBUG=False + +# ========================= +# ALLOWED HOSTS +# ========================= + +# Comma-separated list of allowed host/domain names +# Example: your-app.railway.app,www.your-domain.com +ALLOWED_HOSTS=localhost,127.0.0.1 + +# ========================= +# DATABASE +# ========================= + +# Railway automatically sets this - don't override it in production +# For local development with Railway database: +# DATABASE_URL=postgresql://user:password@host:5432/dbname + +# ========================= +# CORS CONFIGURATION +# ========================= + +# Comma-separated list of allowed origins (frontend URLs) +# Example: https://your-frontend.railway.app,https://www.your-domain.com +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8000 + +# ========================= +# CSRF CONFIGURATION +# ========================= + +# Comma-separated list of trusted origins for CSRF +# Should match your frontend URLs +CSRF_TRUSTED_ORIGINS=http://localhost:3000 + +# ========================= +# EXTERNAL SERVICES +# ========================= + +# OpenAI API Key (for AI features) +OPENAI_API_KEY=your-openai-api-key-here diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..de26716 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,129 @@ +# GitHub Actions CI/CD Setup + +This repository uses GitHub Actions to automatically test and deploy to Railway. + +## Workflow Overview + +The `test-and-deploy.yml` workflow runs on: +- **Push to main branch**: Runs all tests, then deploys if tests pass +- **Pull requests to main**: Runs all tests (no deployment) + +## Jobs + +### 1. Backend Tests +- Sets up Python 3.10 +- Runs PostgreSQL 13 service +- Installs dependencies from `requirements.txt` +- Runs Django test suite (28 tests) + +### 2. Frontend Tests +- Sets up Node.js 24 +- Installs npm dependencies +- Runs Jest unit tests (16 tests) +- Generates coverage report (96.31% coverage) + +### 3. Deploy Backend (main branch only) +- Only runs if both test jobs pass +- Deploys Django backend to Railway +- Requires `RAILWAY_TOKEN` secret + +### 4. Deploy Frontend (main branch only) +- Only runs if both test jobs pass +- Deploys Next.js frontend to Railway +- Requires `RAILWAY_TOKEN` secret + +## Setup Instructions + +### 1. Get Your Railway Token + +```bash +# Install Railway CLI +npm install -g @railway/cli + +# Login to Railway +railway login + +# Get your token (this will open Railway dashboard) +railway whoami +``` + +Then go to Railway Dashboard → Account Settings → Tokens → Create New Token + +### 2. Add Railway Token to GitHub Secrets + +1. Go to your GitHub repository +2. Navigate to Settings → Secrets and variables → Actions +3. Click "New repository secret" +4. Name: `RAILWAY_TOKEN` +5. Value: (paste your Railway token) +6. Click "Add secret" + +### 3. Configure Railway Services + +Make sure you have two services set up in Railway: +- **backend**: Your Django application +- **frontend**: Your Next.js application + +The service names in the workflow must match your Railway service names. If your services have different names, update the workflow file: + +```yaml +# Update these lines with your actual service names +run: railway up --service YOUR_BACKEND_SERVICE_NAME +run: railway up --service YOUR_FRONTEND_SERVICE_NAME --directory frontend/next-app +``` + +## Testing the Workflow + +### Test on Pull Request (Safe) +```bash +git checkout -b test-workflow +git add . +git commit -m "Test GitHub Actions workflow" +git push origin test-workflow +# Create PR on GitHub - tests will run but won't deploy +``` + +### Deploy to Production +```bash +git checkout main +git add . +git commit -m "Add CI/CD workflow" +git push origin main +# Tests will run, then deploy to Railway if tests pass +``` + +## Monitoring + +- View workflow runs: GitHub repository → Actions tab +- Each job shows real-time logs +- Failed tests will block deployment +- You'll get email notifications for failed workflows + +## Local Testing + +Before pushing, you can run the same tests locally: + +```bash +# Run all tests +make test + +# Or individually +make test-backend +make test-frontend +``` + +## Troubleshooting + +**Tests pass locally but fail in CI:** +- Check Python/Node versions match +- Verify all dependencies are in requirements.txt/package.json +- Check for environment-specific code + +**Deployment fails:** +- Verify RAILWAY_TOKEN is correctly set in GitHub secrets +- Verify Railway service names match the workflow +- Check Railway dashboard for service status + +**Token expired:** +- Generate a new Railway token +- Update the RAILWAY_TOKEN secret in GitHub diff --git a/.github/workflows/test-and-deploy.yml b/.github/workflows/test-and-deploy.yml new file mode 100644 index 0000000..dcc4b99 --- /dev/null +++ b/.github/workflows/test-and-deploy.yml @@ -0,0 +1,104 @@ +name: Test and Deploy + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test-backend: + name: Backend Tests + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:13 + env: + POSTGRES_HOST_AUTH_METHOD: trust + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: 'pip' + + - name: Install dependencies + run: | + pip install -r requirements.txt + + - name: Run Django tests + env: + DATABASE_URL: postgresql://postgres@localhost:5432/postgres + run: | + python manage.py test + + test-frontend: + name: Frontend Tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: frontend/next-app/package-lock.json + + - name: Install dependencies + working-directory: frontend/next-app + run: npm ci + + - name: Run Jest tests + working-directory: frontend/next-app + run: npm test + + - name: Run test coverage + working-directory: frontend/next-app + run: npm run test:coverage + + deploy-backend: + name: Deploy Backend to Railway + needs: [test-backend, test-frontend] + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + + steps: + - uses: actions/checkout@v4 + + - name: Install Railway CLI + run: npm install -g @railway/cli + + - name: Deploy to Railway + env: + RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} + run: railway up --service backend + + deploy-frontend: + name: Deploy Frontend to Railway + needs: [test-backend, test-frontend] + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + + steps: + - uses: actions/checkout@v4 + + - name: Install Railway CLI + run: npm install -g @railway/cli + + - name: Deploy to Railway + env: + RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} + run: railway up --service frontend --directory frontend/next-app diff --git a/Dockerfile b/Dockerfile index d70eea2..87f58c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,4 +14,10 @@ COPY ./requirements.txt . RUN pip install -r requirements.txt # Copy project -COPY . . \ No newline at end of file +COPY . . + +# Collect static files for production +RUN python manage.py collectstatic --noinput + +# Run gunicorn in production (Railway will use this) +CMD gunicorn django_project.wsgi:application --bind 0.0.0.0:$PORT \ No newline at end of file diff --git a/PRODUCTION_SECURITY_SUMMARY.md b/PRODUCTION_SECURITY_SUMMARY.md new file mode 100644 index 0000000..e9d337f --- /dev/null +++ b/PRODUCTION_SECURITY_SUMMARY.md @@ -0,0 +1,299 @@ +# Production Security & Deployment - Summary + +## ✅ What Has Been Fixed + +Your Django app had several critical security issues that would have been dangerous in production. Here's what has been secured: + +### 🔴 Before (Insecure) + +```python +DEBUG = True # ❌ Exposed sensitive error pages +ALLOWED_HOSTS = [] # ❌ Anyone could access +CORS_ORIGIN_WHITELIST = ("http://localhost:3000",) # ❌ Only localhost +DATABASES = { # ❌ Hardcoded credentials + "PASSWORD": "postgres", + "HOST": "db", # ❌ Docker-only +} +``` + +### 🟢 After (Secure) + +```python +DEBUG = os.getenv("DEBUG", "False") == "True" # ✅ Disabled by default +ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "...").split(",") # ✅ Configurable +CORS_ALLOWED_ORIGINS = os.getenv("CORS_ALLOWED_ORIGINS", "...").split(",") # ✅ Dynamic +DATABASES = dj_database_url.config(default=DATABASE_URL, ...) # ✅ Railway database +``` + +## 🛡️ Security Features Implemented + +### 1. **Environment-Based Configuration** +- All sensitive settings now use environment variables +- Different configs for development vs production +- No secrets in code + +### 2. **Debug Mode Protection** +- `DEBUG=False` by default (must explicitly enable) +- Production won't expose error details +- Stack traces hidden from attackers + +### 3. **Host Validation** +- `ALLOWED_HOSTS` restricts which domains can serve your app +- Prevents host header attacks +- Must explicitly whitelist Railway domain + +### 4. **CORS Security** +- Only accepts requests from configured frontend domains +- Prevents unauthorized websites from accessing your API +- Configurable per environment + +### 5. **CSRF Protection** +- `CSRF_TRUSTED_ORIGINS` validates request sources +- Protects against cross-site request forgery +- Must match your frontend URL + +### 6. **Database Security** +- Uses Railway's `DATABASE_URL` (encrypted connection) +- No hardcoded credentials +- Connection pooling and health checks enabled + +### 7. **HTTPS Enforcement** (Production Only) +```python +if not DEBUG: + SECURE_SSL_REDIRECT = True # Force HTTPS + SESSION_COOKIE_SECURE = True # Cookies only over HTTPS + CSRF_COOKIE_SECURE = True + SECURE_HSTS_SECONDS = 31536000 # Browser remembers to use HTTPS +``` + +### 8. **Static Files Security** +- WhiteNoise serves static files efficiently +- Compressed and cached +- No need for separate CDN initially + +### 9. **Production Server** +- Uses `gunicorn` instead of Django dev server +- Better performance and security +- Handles concurrent requests properly + +## 📦 New Dependencies Added + +``` +dj-database-url==2.3.0 # Parse Railway DATABASE_URL +gunicorn==23.0.0 # Production WSGI server +whitenoise==6.8.2 # Serve static files +``` + +## 📄 Files Modified + +### [django_project/settings.py](django_project/settings.py) +- ✅ Dynamic DEBUG mode +- ✅ Environment-based ALLOWED_HOSTS +- ✅ Database URL parsing +- ✅ CORS configuration +- ✅ CSRF trusted origins +- ✅ HTTPS enforcement in production +- ✅ WhiteNoise for static files + +### [requirements.txt](requirements.txt) +- ✅ Added production dependencies +- ✅ Alphabetically ordered + +### [Dockerfile](Dockerfile) +- ✅ Collects static files during build +- ✅ Runs gunicorn in production +- ✅ Uses Railway's $PORT variable + +## 📝 Files Created + +### [RAILWAY_DEPLOYMENT.md](RAILWAY_DEPLOYMENT.md) +- Complete step-by-step deployment guide +- Environment variable configuration +- Security checklist +- Troubleshooting guide + +### [.env.example](.env.example) +- Template for environment variables +- Clear documentation for each setting +- Safe to commit (no actual secrets) + +### [PRODUCTION_SECURITY_SUMMARY.md](PRODUCTION_SECURITY_SUMMARY.md) +- This file - overview of security changes + +## 🎯 Required Environment Variables for Railway + +When deploying to Railway, set these in your **backend service**: + +```bash +SECRET_KEY=your-new-secret-key-here +DEBUG=False +ALLOWED_HOSTS=your-backend.railway.app +CORS_ALLOWED_ORIGINS=https://your-frontend.railway.app +CSRF_TRUSTED_ORIGINS=https://your-frontend.railway.app +OPENAI_API_KEY=your-openai-key +``` + +**Note:** `DATABASE_URL` is automatically set by Railway when you add PostgreSQL. + +## 🚀 How the Database is Handled + +### Development (Docker Compose) +```python +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "postgres", + "HOST": "db", # Docker service name + ... + } +} +``` + +### Production (Railway) +```python +DATABASE_URL = os.getenv("DATABASE_URL") # Railway provides this +DATABASES = { + "default": dj_database_url.config( + default=DATABASE_URL, + conn_max_age=600, # Connection pooling + conn_health_checks=True, # Verify connections + ) +} +``` + +The code automatically detects which environment it's in: +- **If `DATABASE_URL` exists** → Use Railway PostgreSQL +- **If `DATABASE_URL` is missing** → Use local Docker PostgreSQL + +## 🔒 CORS Protection + +### How it Works + +```python +# Old (insecure) +CORS_ORIGIN_WHITELIST = ("http://localhost:3000",) # ❌ Hardcoded + +# New (secure) +CORS_ALLOWED_ORIGINS = os.getenv( + "CORS_ALLOWED_ORIGINS", + "http://localhost:3000" # Default for dev +).split(",") +``` + +### In Production +Set `CORS_ALLOWED_ORIGINS=https://your-frontend.railway.app` + +Your backend will **ONLY** accept requests from: +- Your specific frontend domain +- No other websites can access your API +- Prevents data theft + +### What Gets Blocked + +❌ `https://evil-site.com` tries to call your API → **BLOCKED** +❌ `https://random-domain.com` tries to call your API → **BLOCKED** +✅ `https://your-frontend.railway.app` calls your API → **ALLOWED** +✅ `http://localhost:3000` (in dev mode) → **ALLOWED** + +## 🧪 Testing Before Deployment + +All tests must pass before deployment: + +```bash +# Run full test suite +make test + +# Backend: 28 tests +# Frontend: 16 tests +# Total: 44 tests - all must pass ✅ +``` + +The GitHub Actions workflow will: +1. ✅ Run all 44 tests +2. ✅ Check code coverage +3. ❌ **Block deployment if any test fails** +4. ✅ Deploy to Railway only if all tests pass + +This ensures broken code never reaches production! + +## 📊 Security Checklist + +Before deploying, verify: + +- [ ] Generated a strong `SECRET_KEY` (not the fallback) +- [ ] Set `DEBUG=False` in Railway +- [ ] Configured `ALLOWED_HOSTS` with Railway domain +- [ ] Configured `CORS_ALLOWED_ORIGINS` with frontend URL +- [ ] Configured `CSRF_TRUSTED_ORIGINS` with frontend URL +- [ ] Added PostgreSQL database in Railway +- [ ] Set `OPENAI_API_KEY` if using AI features +- [ ] Ran migrations: `railway run python manage.py migrate` +- [ ] Created superuser: `railway run python manage.py createsuperuser` +- [ ] Tested API endpoints work +- [ ] Tested frontend can communicate with backend +- [ ] Verified no CORS errors in browser console +- [ ] Checked Railway logs for errors + +## 🎓 Local Development Still Works + +Your local Docker setup continues to work with no changes: + +```bash +docker-compose up # Works as before +``` + +The settings automatically detect you're in development and use local PostgreSQL. + +## ❓ Questions Answered + +### "How is the database handled?" + +**Development:** Local PostgreSQL in Docker container +**Production:** Railway's managed PostgreSQL via `DATABASE_URL` +**Automatic Detection:** Code checks for `DATABASE_URL` environment variable + +### "Have we made provision for Django to be deployed to production in a way that is safe?" + +**Yes!** All critical security settings are now in place: +- DEBUG disabled +- Host validation +- HTTPS enforcement +- Secure cookies +- Production server (gunicorn) + +### "Have we handled CORS - only accepting requests from specific places?" + +**Yes!** CORS is configured to only allow requests from: +- Your frontend domain (in production) +- `localhost:3000` (in development) +- All other domains are blocked + +## 🚀 Next Steps + +1. **Install new dependencies locally:** + ```bash + docker-compose down + docker-compose build + docker-compose up + ``` + +2. **Review the deployment guide:** + - Read [RAILWAY_DEPLOYMENT.md](RAILWAY_DEPLOYMENT.md) + +3. **Set up Railway:** + - Create account + - Add PostgreSQL database + - Configure environment variables + +4. **Deploy:** + - Option A: Push to GitHub (GitHub Actions handles it) + - Option B: Use Railway CLI + - Option C: Use Railway dashboard + +5. **Verify security:** + - Check DEBUG=False + - Test CORS works + - Try accessing from unauthorized domain (should fail) + - Check HTTPS is enforced + +Your app is now production-ready and secure! 🎉 diff --git a/RAILWAY_DEPLOYMENT.md b/RAILWAY_DEPLOYMENT.md new file mode 100644 index 0000000..8781f39 --- /dev/null +++ b/RAILWAY_DEPLOYMENT.md @@ -0,0 +1,277 @@ +# Railway Deployment Guide + +This guide covers how to deploy your Musicians Practice App to Railway with proper security and production settings. + +## 🔐 Security Features Implemented + +Your Django settings now include: + +- ✅ **DEBUG mode disabled in production** (via environment variable) +- ✅ **ALLOWED_HOSTS restricted** to specific domains +- ✅ **CORS configured** to only accept requests from your frontend +- ✅ **CSRF protection** with trusted origins +- ✅ **HTTPS enforcement** in production +- ✅ **Secure cookies** for sessions and CSRF +- ✅ **Database connection** via Railway's DATABASE_URL +- ✅ **Static files** served via WhiteNoise + +## 📋 Prerequisites + +1. Railway account ([railway.app](https://railway.app)) +2. GitHub repository connected to Railway +3. Railway CLI installed: `npm install -g @railway/cli` + +## 🚀 Step 1: Create Railway Project + +```bash +# Login to Railway +railway login + +# Create new project (or use Railway dashboard) +railway init +``` + +## 🗄️ Step 2: Add PostgreSQL Database + +In Railway dashboard: +1. Click "New" → "Database" → "PostgreSQL" +2. Railway automatically provisions a database +3. Railway automatically sets `DATABASE_URL` environment variable +4. Your Django app will automatically use this database + +## 🔧 Step 3: Configure Backend Environment Variables + +In Railway dashboard, go to your **backend service** and add these variables: + +### Required Variables + +```bash +# Django Secret Key (generate a new one!) +SECRET_KEY=your-super-secret-key-here-generate-a-new-one + +# Debug Mode (MUST be False in production) +DEBUG=False + +# Allowed Hosts (your Railway backend domain) +ALLOWED_HOSTS=your-backend.railway.app,localhost,127.0.0.1 + +# CORS - Allow your frontend to make requests +CORS_ALLOWED_ORIGINS=https://your-frontend.railway.app,http://localhost:3000 + +# CSRF - Trust your frontend for CSRF tokens +CSRF_TRUSTED_ORIGINS=https://your-frontend.railway.app,http://localhost:3000 + +# OpenAI API Key (if you're using OpenAI features) +OPENAI_API_KEY=your-openai-api-key +``` + +### How to Generate a SECRET_KEY + +```python +# Run this in Python to generate a secure secret key +import secrets +print(secrets.token_urlsafe(50)) +``` + +Or use this Django command: +```bash +python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())' +``` + +### Important Notes + +- **DATABASE_URL** is automatically set by Railway when you add PostgreSQL - don't set it manually +- Replace `your-backend.railway.app` with your actual Railway backend domain +- Replace `your-frontend.railway.app` with your actual Railway frontend domain +- You'll get these domains after deploying (see Step 5) + +## 🎨 Step 4: Configure Frontend Environment Variables + +In Railway dashboard, go to your **frontend service** and add: + +```bash +# Backend API URL +NEXT_PUBLIC_API_URL=https://your-backend.railway.app +``` + +## 🔄 Step 5: Deploy Services + +### Option A: Via GitHub Actions (Recommended) + +1. Add `RAILWAY_TOKEN` to GitHub secrets (see [.github/workflows/README.md](.github/workflows/README.md)) +2. Push to main branch +3. Tests run automatically +4. If tests pass, deploys to Railway + +### Option B: Via Railway CLI + +```bash +# Deploy backend +railway up --service backend + +# Deploy frontend +railway up --service frontend --directory frontend/next-app +``` + +### Option C: Via Railway Dashboard + +1. Connect your GitHub repository +2. Railway auto-detects Dockerfile +3. Click "Deploy" + +## 📝 Step 6: Run Database Migrations + +After first deployment, run migrations: + +```bash +# Using Railway CLI +railway run python manage.py migrate + +# Or in Railway dashboard, go to your backend service and run: +# Settings → Deploy → Command Override → Add: python manage.py migrate && gunicorn django_project.wsgi +``` + +## 👤 Step 7: Create Superuser + +```bash +# Using Railway CLI +railway run python manage.py createsuperuser +``` + +## 🔍 Step 8: Get Your Deployment URLs + +After deployment, Railway provides URLs for your services: + +``` +Backend: https://your-project-name-backend.railway.app +Frontend: https://your-project-name-frontend.railway.app +``` + +**IMPORTANT:** Go back to Step 3 and update the environment variables with these actual URLs! + +## 🎯 Step 9: Update Environment Variables with Real URLs + +Now that you have your Railway URLs, update these variables: + +### Backend Service +```bash +ALLOWED_HOSTS=your-actual-backend.railway.app,localhost,127.0.0.1 +CORS_ALLOWED_ORIGINS=https://your-actual-frontend.railway.app +CSRF_TRUSTED_ORIGINS=https://your-actual-frontend.railway.app +``` + +### Frontend Service +```bash +NEXT_PUBLIC_API_URL=https://your-actual-backend.railway.app +``` + +After updating, Railway will automatically redeploy your services. + +## ✅ Step 10: Verify Deployment + +### Test Backend +```bash +# Health check +curl https://your-backend.railway.app/api/schema/ + +# Should return OpenAPI schema +``` + +### Test Frontend +Visit `https://your-frontend.railway.app` in your browser + +### Check CORS is Working +1. Open your frontend in browser +2. Open DevTools Console +3. Try logging in or making API requests +4. Should work without CORS errors + +## 🔒 Security Checklist + +Before going live, verify: + +- [ ] `DEBUG=False` in production +- [ ] `SECRET_KEY` is a strong, unique value (not the fallback) +- [ ] `ALLOWED_HOSTS` only includes your Railway domain +- [ ] `CORS_ALLOWED_ORIGINS` only includes your frontend domain +- [ ] `CSRF_TRUSTED_ORIGINS` only includes your frontend domain +- [ ] Database is Railway's PostgreSQL (not local Docker) +- [ ] HTTPS is enforced (Railway handles this automatically) +- [ ] All environment variables are set correctly +- [ ] No hardcoded secrets in code + +## 🐛 Troubleshooting + +### "DisallowedHost" Error +- Check `ALLOWED_HOSTS` includes your Railway domain +- Restart the backend service after updating + +### CORS Errors +- Check `CORS_ALLOWED_ORIGINS` includes your frontend URL with `https://` +- Make sure there are no trailing slashes +- Restart backend service after updating + +### Database Connection Error +- Verify PostgreSQL database is running in Railway +- Check `DATABASE_URL` is set automatically (don't override it) +- Run migrations: `railway run python manage.py migrate` + +### Static Files Not Loading +- Verify WhiteNoise is in MIDDLEWARE (already configured) +- Run: `railway run python manage.py collectstatic --noinput` + +### 502 Bad Gateway +- Check Railway logs for errors +- Verify Dockerfile builds successfully +- Make sure gunicorn is running (not Django dev server) + +## 📊 Monitoring + +View logs in Railway dashboard: +- Backend service → Deployments → View Logs +- Frontend service → Deployments → View Logs + +## 🔄 CI/CD Workflow + +Your GitHub Actions workflow ([.github/workflows/test-and-deploy.yml](.github/workflows/test-and-deploy.yml)) automatically: + +1. ✅ Runs 28 backend tests +2. ✅ Runs 16 frontend tests +3. ✅ Checks code coverage +4. ✅ Deploys to Railway (only if all tests pass) + +This ensures no broken code reaches production! + +## 📦 Database Backup + +Railway automatically backs up your PostgreSQL database. To manually export: + +```bash +# Export database +railway run pg_dump > backup.sql + +# Import database +railway run psql < backup.sql +``` + +## 🎓 Local Development + +Your settings.py is configured to work seamlessly in both environments: + +**Development (Docker):** +- Uses local PostgreSQL container +- `DEBUG=True` (default) +- Accepts `localhost` origins + +**Production (Railway):** +- Uses Railway PostgreSQL (`DATABASE_URL`) +- `DEBUG=False` (via env var) +- Only accepts configured domains + +No code changes needed - just environment variables! + +## 📞 Support + +- Railway Docs: [docs.railway.app](https://docs.railway.app) +- Railway Discord: [discord.gg/railway](https://discord.gg/railway) +- GitHub Issues: Report bugs in your repository diff --git a/django_project/settings.py b/django_project/settings.py index 34aac25..d10d303 100644 --- a/django_project/settings.py +++ b/django_project/settings.py @@ -12,6 +12,7 @@ from pathlib import Path import os +import dj_database_url # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -24,9 +25,10 @@ SECRET_KEY = os.getenv("SECRET_KEY", "django-insecure-fallback-key-for-dev-only") # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = os.getenv("DEBUG", "False") == "True" -ALLOWED_HOSTS = [] +# ALLOWED_HOSTS - restrict to specific domains in production +ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") # Application definition @@ -59,6 +61,7 @@ MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", # Serve static files in production "django.contrib.sessions.middleware.SessionMiddleware", "corsheaders.middleware.CorsMiddleware", "django.middleware.common.CommonMiddleware", @@ -99,16 +102,30 @@ # Database # https://docs.djangoproject.com/en/4.2/ref/settings/#databases -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": "postgres", - "USER": "postgres", - "PASSWORD": "postgres", - "HOST": "db", - "PORT": 5432, +# Use Railway's DATABASE_URL in production, fallback to local for development +DATABASE_URL = os.getenv("DATABASE_URL") + +if DATABASE_URL: + # Production: Use Railway's PostgreSQL database + DATABASES = { + "default": dj_database_url.config( + default=DATABASE_URL, + conn_max_age=600, + conn_health_checks=True, + ) + } +else: + # Development: Use local Docker PostgreSQL + DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "postgres", + "USER": "postgres", + "PASSWORD": "postgres", + "HOST": "db", + "PORT": 5432, + } } -} # Password validation # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators @@ -144,6 +161,17 @@ # https://docs.djangoproject.com/en/4.2/howto/static-files/ STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" + +# WhiteNoise configuration for serving static files in production +STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} # Default primary key field type # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field @@ -162,12 +190,33 @@ "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", } -CORS_ORIGIN_WHITELIST = ( - "http://localhost:3000", - "http://localhost:8000", -) - -CSRF_TRUSTED_ORIGINS = ["http://localhost:3000"] +# CORS Configuration - Allow frontend to make requests to backend +# Get allowed origins from environment variable for production +CORS_ALLOWED_ORIGINS = os.getenv( + "CORS_ALLOWED_ORIGINS", + "http://localhost:3000,http://localhost:8000" +).split(",") + +# For development, allow credentials (cookies, auth headers) +CORS_ALLOW_CREDENTIALS = True + +# CSRF Configuration - Trust these origins for CSRF tokens +CSRF_TRUSTED_ORIGINS = os.getenv( + "CSRF_TRUSTED_ORIGINS", + "http://localhost:3000" +).split(",") + +# Security settings for production +if not DEBUG: + # Force HTTPS in production + SECURE_SSL_REDIRECT = True + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + SECURE_BROWSER_XSS_FILTER = True + SECURE_CONTENT_TYPE_NOSNIFF = True + SECURE_HSTS_SECONDS = 31536000 # 1 year + SECURE_HSTS_INCLUDE_SUBDOMAINS = True + SECURE_HSTS_PRELOAD = True SPECTACULAR_SETTINGS = { diff --git a/requirements.txt b/requirements.txt index 0931d88..7846812 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,12 +9,15 @@ click==8.1.8 cryptography==44.0.0 defusedxml==0.7.1 distro==1.9.0 +dj-database-url==2.3.0 dj-rest-auth==7.0.0 Django==5.1.4 django-allauth==65.4.1 django-cors-headers==3.10.0 djangorestframework==3.15.2 drf-spectacular==0.21.0 +gunicorn==23.0.0 +whitenoise==6.8.2 exceptiongroup==1.2.2 h11==0.14.0 httpcore==1.0.7 From 592dd79adfd22f4e0cc2bc2214401cd23a0a9963 Mon Sep 17 00:00:00 2001 From: Daniel Adekugbe Date: Sun, 1 Mar 2026 02:40:18 +0000 Subject: [PATCH 02/14] Add AI practice recommendations feature with backend and frontend - Rewrite PracticeRecommendationView to use OpenAI v1+ SDK (gpt-4o-mini) - Add input validation for skill_level and instrument against model choices - Create recommendations page with auth-protected form and result display - Add Recommendations link to Header and MobileNav navigation - Enable dashboard quick action card to link to recommendations - Add 6 backend tests for recommendation endpoint (mocked OpenAI) - Set DEBUG=True in docker-compose for local development Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 1 + frontend/next-app/src/app/dashboard/page.tsx | 7 +- .../next-app/src/app/recommendations/page.tsx | 162 ++++++++++++++++++ .../src/components/navigation/Header.tsx | 1 + .../src/components/navigation/MobileNav.tsx | 1 + session/tests.py | 77 +++++++++ session/views.py | 48 +++--- 7 files changed, 267 insertions(+), 30 deletions(-) create mode 100644 frontend/next-app/src/app/recommendations/page.tsx diff --git a/docker-compose.yml b/docker-compose.yml index 3b58f25..ba9bbee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: depends_on: - db environment: + - DEBUG=True - OPENAI_API_KEY db: image: postgres:13 diff --git a/frontend/next-app/src/app/dashboard/page.tsx b/frontend/next-app/src/app/dashboard/page.tsx index 91cdd92..0d8defe 100644 --- a/frontend/next-app/src/app/dashboard/page.tsx +++ b/frontend/next-app/src/app/dashboard/page.tsx @@ -127,10 +127,13 @@ export default function DashboardPage() { See your complete practice history and charts

-
+
router.push('/recommendations')} + className="cursor-pointer rounded-lg border bg-card p-6 hover:bg-accent transition-colors" + >

Practice Recommendations

- Get AI-powered practice suggestions (Coming soon) + Get AI-powered practice suggestions

diff --git a/frontend/next-app/src/app/recommendations/page.tsx b/frontend/next-app/src/app/recommendations/page.tsx new file mode 100644 index 0000000..96d7dc8 --- /dev/null +++ b/frontend/next-app/src/app/recommendations/page.tsx @@ -0,0 +1,162 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import axios from "axios"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Loader2 } from "lucide-react"; + +export default function RecommendationsPage() { + const router = useRouter(); + const [instrument, setInstrument] = useState(""); + const [skillLevel, setSkillLevel] = useState(""); + const [goals, setGoals] = useState(""); + const [recommendation, setRecommendation] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + + const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1"; + + useEffect(() => { + const token = localStorage.getItem("token"); + if (!token) { + router.push("/login"); + } + }, [router]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setRecommendation(""); + setIsLoading(true); + + const token = localStorage.getItem("token"); + if (!token) { + router.push("/login"); + return; + } + + try { + const response = await axios.post( + `${apiBaseUrl}/recommendations/`, + { instrument, skill_level: skillLevel, goals }, + { headers: { Authorization: `Token ${token}` } } + ); + setRecommendation(response.data.recommendation); + } catch (err: unknown) { + if (axios.isAxiosError(err) && err.response?.data?.error) { + setError(err.response.data.error); + } else { + setError("Failed to get recommendation. Please try again."); + } + } finally { + setIsLoading(false); + } + }; + + const selectClassName = + "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"; + + return ( +
+
+

+ Practice Recommendations +

+

+ Get AI-powered practice suggestions tailored to your skill level and goals +

+
+ + + + Get a Recommendation + + Fill in your details and we'll generate a personalized practice plan + + + +
+
+ + +
+ +
+ + +
+ +
+ + setGoals(e.target.value)} + required + disabled={isLoading} + /> +
+ + {error && ( +

{error}

+ )} + + +
+
+
+ + {recommendation && ( + + + Your Practice Recommendation + + +

+ {recommendation} +

+
+
+ )} +
+ ); +} diff --git a/frontend/next-app/src/components/navigation/Header.tsx b/frontend/next-app/src/components/navigation/Header.tsx index cfc3f64..be26bd4 100644 --- a/frontend/next-app/src/components/navigation/Header.tsx +++ b/frontend/next-app/src/components/navigation/Header.tsx @@ -16,6 +16,7 @@ export function Header() { { name: "Dashboard", href: "/dashboard" }, { name: "Profile", href: "/profilepage" }, { name: "Practice Timer", href: "/practice-timer" }, + { name: "Recommendations", href: "/recommendations" }, ]; // Don't show header on login/register pages diff --git a/frontend/next-app/src/components/navigation/MobileNav.tsx b/frontend/next-app/src/components/navigation/MobileNav.tsx index ba10165..245dfef 100644 --- a/frontend/next-app/src/components/navigation/MobileNav.tsx +++ b/frontend/next-app/src/components/navigation/MobileNav.tsx @@ -16,6 +16,7 @@ export function MobileNav() { { name: "Dashboard", href: "/dashboard" }, { name: "Profile", href: "/profilepage" }, { name: "Practice Timer", href: "/practice-timer" }, + { name: "Recommendations", href: "/recommendations" }, ]; // Don't show on login/register pages diff --git a/session/tests.py b/session/tests.py index 414f829..f5e5816 100644 --- a/session/tests.py +++ b/session/tests.py @@ -6,6 +6,7 @@ from rest_framework import status from rest_framework.authtoken.models import Token from datetime import timedelta, date, datetime +from unittest.mock import patch, MagicMock from .models import Session, Tag @@ -511,3 +512,79 @@ def test_delete_tag(self): self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual(Tag.objects.count(), 0) + + +class RecommendationAPITests(APITestCase): + """Test cases for Practice Recommendation endpoint""" + + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + username="testuser", + email="test@email.com", + password="testpass123" + ) + self.token = Token.objects.create(user=self.user) + self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) + self.url = reverse('practice-recommendation') + self.valid_data = { + 'instrument': 'guitar', + 'skill_level': 'beginner', + 'goals': 'improve finger picking technique' + } + + def test_missing_fields_returns_400(self): + """Test that missing required fields returns 400""" + response = self.client.post(self.url, {}, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('error', response.data) + + def test_invalid_skill_level_returns_400(self): + """Test that invalid skill_level returns 400""" + data = {**self.valid_data, 'skill_level': 'expert'} + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('error', response.data) + + def test_invalid_instrument_returns_400(self): + """Test that invalid instrument returns 400""" + data = {**self.valid_data, 'instrument': 'theremin'} + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('error', response.data) + + @patch('session.views.OpenAI') + def test_successful_recommendation(self, mock_openai_cls): + """Test successful recommendation with mocked OpenAI""" + mock_client = MagicMock() + mock_openai_cls.return_value = mock_client + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = 'Practice scales for 15 minutes daily.' + mock_client.chat.completions.create.return_value = mock_response + + response = self.client.post(self.url, self.valid_data, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['recommendation'], 'Practice scales for 15 minutes daily.') + self.assertEqual(response.data['instrument'], 'guitar') + self.assertEqual(response.data['skill_level'], 'beginner') + self.assertEqual(response.data['goals'], 'improve finger picking technique') + + def test_unauthenticated_returns_401_or_403(self): + """Test that unauthenticated requests are rejected""" + self.client.credentials() + response = self.client.post(self.url, self.valid_data, format='json') + self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]) + + @patch('session.views.OpenAI') + def test_openai_error_returns_500(self, mock_openai_cls): + """Test that OpenAI API errors return 500""" + mock_client = MagicMock() + mock_openai_cls.return_value = mock_client + mock_client.chat.completions.create.side_effect = Exception('API rate limit exceeded') + + response = self.client.post(self.url, self.valid_data, format='json') + + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertIn('error', response.data) diff --git a/session/views.py b/session/views.py index 4a2bfa1..3223ac4 100644 --- a/session/views.py +++ b/session/views.py @@ -10,11 +10,9 @@ from django.db.models import Max, Sum, Count, Q from django.utils import timezone from datetime import timedelta, datetime -import openai +from openai import OpenAI import os -openai.api_key = os.environ.get('OPENAI_API_KEY') - class SessionList(generics.ListCreateAPIView): permission_classes = (IsAdminOrOwner,) serializer_class = SessionSerializer @@ -41,46 +39,40 @@ class PracticeRecommendationView(APIView): permission_classes = (IsAdminOrOwner,) def post(self, request): - user_id = request.data.get('user_id') skill_level = request.data.get('skill_level') instrument = request.data.get('instrument') goals = request.data.get('goals') - if not user_id or not skill_level or not instrument or not goals: + if not skill_level or not instrument or not goals: return Response({'error': 'Missing required fields'}, status=status.HTTP_400_BAD_REQUEST) - try: - user = User.objects.get(id=user_id) - except User.DoesNotExist: - return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND) + valid_skill_levels = [choice[0] for choice in Session.SKILL_LEVEL_CHOICES] + if skill_level not in valid_skill_levels: + return Response({'error': f'Invalid skill_level. Must be one of: {", ".join(valid_skill_levels)}'}, status=status.HTTP_400_BAD_REQUEST) - prompt = f"Generate a practice recommendation for a {skill_level} level {instrument} player who wants to focus on {goals}." + valid_instruments = [choice[0] for choice in Session.INSTRUMENT_CHOICES] + if instrument not in valid_instruments: + return Response({'error': f'Invalid instrument. Must be one of: {", ".join(valid_instruments)}'}, status=status.HTTP_400_BAD_REQUEST) try: - response = openai.Completion.create( - engine='text-davinci-002', - prompt=prompt, - max_tokens=100, - n=1, - stop=None, + client = OpenAI(api_key=os.environ.get('OPENAI_API_KEY')) + response = client.chat.completions.create( + model='gpt-4o-mini', + messages=[ + {'role': 'system', 'content': 'You are an experienced music teacher who provides detailed, actionable practice recommendations.'}, + {'role': 'user', 'content': f'Generate a practice recommendation for a {skill_level} level {instrument} player who wants to focus on {goals}.'} + ], + max_tokens=1000, temperature=0.7, ) - recommendation = response.choices[0].text.strip() + recommendation = response.choices[0].message.content.strip() - session_data = { - 'user': user.id, + return Response({ + 'recommendation': recommendation, 'instrument': instrument, 'skill_level': skill_level, 'goals': goals, - 'recommendation': recommendation, - } - - serializer = SessionSerializer(data=session_data) - if serializer.is_valid(): - session = serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - else: - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + }, status=status.HTTP_200_OK) except Exception as e: return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) From 9b49f72136baa033db261c744f2b6b8617ccfde5 Mon Sep 17 00:00:00 2001 From: Daniel Adekugbe Date: Sun, 1 Mar 2026 08:56:31 +0000 Subject: [PATCH 03/14] Add YouTube practice player page with video library New standalone page at /youtube-practice with embedded YouTube player, playback speed controls (0.5x-1.25x), A-B loop, timestamp display, session save form, and a video library of past practice sessions. Adds YouTube components, types, backend youtube_url field, and navigation links. Co-Authored-By: Claude Opus 4.6 --- ...26-03-01-youtube-practice-player-design.md | 39 ++ .../next-app/src/app/practice-timer/page.tsx | 409 ++++++++++------ .../src/app/youtube-practice/page.tsx | 443 ++++++++++++++++++ .../src/components/navigation/Header.tsx | 1 + .../src/components/navigation/MobileNav.tsx | 1 + .../src/components/profile/ProfilePage.tsx | 18 +- .../src/components/youtube/ABLoopControl.tsx | 115 +++++ .../youtube/PlaybackSpeedControl.tsx | 35 ++ .../src/components/youtube/YouTubePlayer.tsx | 123 +++++ frontend/next-app/src/types/youtube.d.ts | 60 +++ .../migrations/0008_session_youtube_url.py | 18 + session/models.py | 1 + session/serializers.py | 1 + session/tests.py | 66 +++ session/views.py | 2 + 15 files changed, 1192 insertions(+), 140 deletions(-) create mode 100644 docs/plans/2026-03-01-youtube-practice-player-design.md create mode 100644 frontend/next-app/src/app/youtube-practice/page.tsx create mode 100644 frontend/next-app/src/components/youtube/ABLoopControl.tsx create mode 100644 frontend/next-app/src/components/youtube/PlaybackSpeedControl.tsx create mode 100644 frontend/next-app/src/components/youtube/YouTubePlayer.tsx create mode 100644 frontend/next-app/src/types/youtube.d.ts create mode 100644 session/migrations/0008_session_youtube_url.py diff --git a/docs/plans/2026-03-01-youtube-practice-player-design.md b/docs/plans/2026-03-01-youtube-practice-player-design.md new file mode 100644 index 0000000..59b19f3 --- /dev/null +++ b/docs/plans/2026-03-01-youtube-practice-player-design.md @@ -0,0 +1,39 @@ +# YouTube Practice Player — Design + +## Overview + +A video library + player page at `/youtube-practice` that lets users browse past practice sessions with YouTube URLs, load videos into an embedded player with speed/loop controls, and save new sessions. + +## Layout + +Single-page scroll layout (Approach A): + +1. **Header**: Title + subtitle +2. **URL Input**: Paste YouTube URL field +3. **Player Section**: Embedded YouTube player (16:9), current timestamp display, playback speed buttons (0.5x, 0.75x, 1x, 1.25x), A-B loop controls +4. **Save Form**: Instrument, description, duration (HH:MM:SS), session_date — POST to `/api/v1/sessions/` with Token auth +5. **Video Library**: Grid of past sessions with YouTube URLs, showing thumbnails + instrument + date. Clicking loads into player. + +## Data Flow + +- **GET** `/api/v1/sessions/` → filter client-side for non-empty `youtube_url` → display as library cards +- **POST** `/api/v1/sessions/` → `{instrument, duration, description, session_date, youtube_url}` with `Authorization: Token ` +- Library thumbnails: `https://img.youtube.com/vi/{videoId}/mqdefault.jpg` + +## Components Reused + +- `YouTubePlayer` (existing) — embedded player with iframe API +- `PlaybackSpeedControl` (existing) — will use custom 4-speed subset inline instead +- `ABLoopControl` (existing) — A/B loop with polling + +## Files + +| Action | File | +|--------|------| +| Create | `frontend/next-app/src/app/youtube-practice/page.tsx` | +| Edit | `frontend/next-app/src/components/navigation/Header.tsx` | +| Edit | `frontend/next-app/src/components/navigation/MobileNav.tsx` | + +## Styling + +Match practice-timer page: Card components, same spacing, muted-foreground text, lucide icons. diff --git a/frontend/next-app/src/app/practice-timer/page.tsx b/frontend/next-app/src/app/practice-timer/page.tsx index a82772f..849b429 100644 --- a/frontend/next-app/src/app/practice-timer/page.tsx +++ b/frontend/next-app/src/app/practice-timer/page.tsx @@ -1,13 +1,17 @@ "use client"; -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import axios from 'axios'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Play, Square, Clock, Pause } from 'lucide-react'; +import { Play, Square, Clock, Pause, Youtube } from 'lucide-react'; import { useRouter } from 'next/navigation'; +import { cn } from '@/lib/utils'; +import YouTubePlayer, { extractVideoId, YouTubePlayerHandle } from '@/components/youtube/YouTubePlayer'; +import PlaybackSpeedControl from '@/components/youtube/PlaybackSpeedControl'; +import ABLoopControl from '@/components/youtube/ABLoopControl'; export default function PracticeTimerPage() { const [isRunning, setIsRunning] = useState(false); @@ -16,14 +20,20 @@ export default function PracticeTimerPage() { const [elapsedSeconds, setElapsedSeconds] = useState(0); const [instrument, setInstrument] = useState(''); const [description, setDescription] = useState(''); + const [youtubeUrl, setYoutubeUrl] = useState(''); + const [playbackSpeed, setPlaybackSpeed] = useState(1); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); const intervalRef = useRef(null); + const youtubePlayerRef = useRef(null); const router = useRouter(); const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1'; + const videoId = extractVideoId(youtubeUrl); + const hasVideo = Boolean(videoId); + useEffect(() => { // Check if there's an active timer const checkActiveTimer = async () => { @@ -43,6 +53,7 @@ export default function PracticeTimerPage() { setSessionId(session.session_id); setInstrument(session.instrument); setDescription(session.description); + setYoutubeUrl(session.youtube_url || ''); setIsRunning(true); setIsPaused(session.is_paused || false); @@ -98,7 +109,7 @@ export default function PracticeTimerPage() { try { const response = await axios.post( `${apiBaseUrl}/timer/start/`, - { instrument, description }, + { instrument, description, youtube_url: youtubeUrl }, { headers: { 'Authorization': `Token ${token}` } } ); @@ -129,11 +140,19 @@ export default function PracticeTimerPage() { { headers: { 'Authorization': `Token ${token}` } } ); + // Clean up YouTube player + const player = youtubePlayerRef.current?.getPlayer(); + if (player) { + player.destroy(); + } + setIsRunning(false); setIsPaused(false); setSessionId(null); setInstrument(''); setDescription(''); + setYoutubeUrl(''); + setPlaybackSpeed(1); setElapsedSeconds(0); // Redirect to profile page to see the completed session @@ -195,8 +214,37 @@ export default function PracticeTimerPage() { } }; + const handleSpeedChange = useCallback((speed: number) => { + setPlaybackSpeed(speed); + const player = youtubePlayerRef.current?.getPlayer(); + if (player) { + player.setPlaybackRate(speed); + } + }, []); + + const getPlayer = useCallback(() => { + return youtubePlayerRef.current?.getPlayer() ?? null; + }, []); + + const handleUpdateYoutubeUrl = async (newUrl: string) => { + setYoutubeUrl(newUrl); + if (!sessionId) return; + + const token = localStorage.getItem('token'); + try { + await axios.patch( + `${apiBaseUrl}/${sessionId}/`, + { youtube_url: newUrl }, + { headers: { 'Authorization': `Token ${token}` } } + ); + } catch { + // Non-critical: URL still works locally even if save fails + console.error('Failed to save YouTube URL'); + } + }; + return ( -
+

Practice Timer

@@ -204,150 +252,233 @@ export default function PracticeTimerPage() {

- - - - - {isRunning ? (isPaused ? 'Session Paused' : 'Session in Progress') : 'Start New Session'} - - - {isRunning - ? (isPaused ? 'Your session is paused. Resume when ready.' : 'Your practice session is being tracked') - : 'Enter your instrument and start practicing'} - - - - {/* Timer Display */} -
-
- {formatTime(elapsedSeconds)} -
-

- {isRunning ? (isPaused ? '⏸ Paused' : 'Time elapsed') : 'Ready to start'} -

-
- - {/* Form */} - {!isRunning && ( -
-
- - setInstrument(e.target.value)} - placeholder="e.g., Guitar, Piano, Drums" - required - disabled={isRunning} - /> +
+ {/* Left column: Timer */} +
+ + + + + {isRunning ? (isPaused ? 'Session Paused' : 'Session in Progress') : 'Start New Session'} + + + {isRunning + ? (isPaused ? 'Your session is paused. Resume when ready.' : 'Your practice session is being tracked') + : 'Enter your instrument and start practicing'} + + + + {/* Timer Display */} +
+
+ {formatTime(elapsedSeconds)} +
+

+ {isRunning ? (isPaused ? '⏸ Paused' : 'Time elapsed') : 'Ready to start'} +

-
- - setDescription(e.target.value)} - placeholder="e.g., Scales practice, Song rehearsal" - disabled={isRunning} - /> -
-
- )} - {isRunning && ( -
-

Current Session

-

Instrument: {instrument}

- {description && ( -

Description: {description}

+ {/* Form */} + {!isRunning && ( +
+
+ + setInstrument(e.target.value)} + placeholder="e.g., Guitar, Piano, Drums" + required + disabled={isRunning} + /> +
+
+ + setDescription(e.target.value)} + placeholder="e.g., Scales practice, Song rehearsal" + disabled={isRunning} + /> +
+
+ + setYoutubeUrl(e.target.value)} + placeholder="e.g., https://www.youtube.com/watch?v=..." + /> +
+
)} -
- )} - {error && ( -
- {error} -
- )} + {isRunning && ( +
+

Current Session

+

Instrument: {instrument}

+ {description && ( +

Description: {description}

+ )} +
+ )} + + {/* Mid-session YouTube URL input */} + {isRunning && !hasVideo && ( +
+ + handleUpdateYoutubeUrl(e.target.value)} + placeholder="Paste a YouTube URL..." + /> +
+ )} + + {error && ( +
+ {error} +
+ )} - {/* Controls */} - {!isRunning ? ( -
- -
- ) : ( -
- {isPaused ? ( - + {/* Controls */} + {!isRunning ? ( +
+ +
) : ( - +
+ {isPaused ? ( + + ) : ( + + )} + +
)} - -
- )} - {isRunning && ( -

- {isPaused - ? 'Session paused. Click "Resume" to continue or "Stop & Save" to end.' - : 'Click "Pause" to take a break, or "Stop & Save" to end your session.'} -

+ {isRunning && ( +

+ {isPaused + ? 'Session paused. Click "Resume" to continue or "Stop & Save" to end.' + : 'Click "Pause" to take a break, or "Stop & Save" to end your session.'} +

+ )} + + + + {/* Tips Card */} + {!isRunning && ( + + + Practice Tips + + +
    +
  • • Set a goal before starting your session
  • +
  • • Take short breaks every 25-30 minutes
  • +
  • • Practice slowly first, then gradually increase tempo
  • +
  • • Record yourself to track improvement
  • +
+
+
)} - - - - {/* Tips Card */} - {!isRunning && ( - - - Practice Tips - - -
    -
  • • Set a goal before starting your session
  • -
  • • Take short breaks every 25-30 minutes
  • -
  • • Practice slowly first, then gradually increase tempo
  • -
  • • Record yourself to track improvement
  • -
-
-
- )} +
+ + {/* Right column: YouTube Player */} + {hasVideo && isRunning && ( +
+ + + + + YouTube Video + + + + { + if (playbackSpeed !== 1) { + player.setPlaybackRate(playbackSpeed); + } + }} + /> + + + + {/* Change video URL */} +
+ + handleUpdateYoutubeUrl(e.target.value)} + placeholder="Paste a different YouTube URL..." + className="text-sm" + /> +
+
+
+
+ )} +
); } diff --git a/frontend/next-app/src/app/youtube-practice/page.tsx b/frontend/next-app/src/app/youtube-practice/page.tsx new file mode 100644 index 0000000..d555618 --- /dev/null +++ b/frontend/next-app/src/app/youtube-practice/page.tsx @@ -0,0 +1,443 @@ +"use client"; + +import React, { useState, useEffect, useRef, useCallback } from "react"; +import axios from "axios"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Youtube, Save, Clock, Library } from "lucide-react"; +import YouTubePlayer, { + extractVideoId, + YouTubePlayerHandle, +} from "@/components/youtube/YouTubePlayer"; +import ABLoopControl from "@/components/youtube/ABLoopControl"; + +const SPEEDS = [0.5, 0.75, 1, 1.25]; + +interface SessionData { + session_id: number; + instrument: string; + description: string; + youtube_url: string; + session_date: string; + duration: string; +} + +function formatTimestamp(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; +} + +export default function YouTubePracticePage() { + const [youtubeUrl, setYoutubeUrl] = useState(""); + const [playbackSpeed, setPlaybackSpeed] = useState(1); + const [currentTime, setCurrentTime] = useState(0); + const [videoDuration, setVideoDuration] = useState(0); + const [playerReady, setPlayerReady] = useState(false); + + // Save form state + const [instrument, setInstrument] = useState(""); + const [description, setDescription] = useState(""); + const [duration, setDuration] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(""); + const [saveSuccess, setSaveSuccess] = useState(false); + + // Library state + const [librarySessions, setLibrarySessions] = useState([]); + const [isLoadingLibrary, setIsLoadingLibrary] = useState(true); + + const youtubePlayerRef = useRef(null); + const timestampIntervalRef = useRef(null); + const router = useRouter(); + + const apiBaseUrl = + process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1"; + + const videoId = extractVideoId(youtubeUrl); + const hasVideo = Boolean(videoId); + + // Fetch library of past sessions with YouTube URLs + useEffect(() => { + const fetchLibrary = async () => { + const token = localStorage.getItem("token"); + if (!token) { + router.push("/login"); + return; + } + + try { + const response = await axios.get(`${apiBaseUrl}/sessions/`, { + headers: { Authorization: `Token ${token}` }, + }); + const sessionsWithVideo = response.data.filter( + (s: SessionData) => s.youtube_url && s.youtube_url.trim() !== "" + ); + setLibrarySessions(sessionsWithVideo); + } catch (error) { + console.error("Failed to fetch sessions", error); + } finally { + setIsLoadingLibrary(false); + } + }; + + fetchLibrary(); + }, [apiBaseUrl, router]); + + // Update current timestamp while playing + useEffect(() => { + if (playerReady) { + timestampIntervalRef.current = setInterval(() => { + const player = youtubePlayerRef.current?.getPlayer(); + if (player) { + setCurrentTime(player.getCurrentTime()); + setVideoDuration(player.getDuration()); + } + }, 500); + } + + return () => { + if (timestampIntervalRef.current) { + clearInterval(timestampIntervalRef.current); + timestampIntervalRef.current = null; + } + }; + }, [playerReady]); + + const handlePlayerReady = useCallback( + (player: YT.Player) => { + setPlayerReady(true); + if (playbackSpeed !== 1) { + player.setPlaybackRate(playbackSpeed); + } + }, + [playbackSpeed] + ); + + const handleSpeedChange = useCallback((speed: number) => { + setPlaybackSpeed(speed); + const player = youtubePlayerRef.current?.getPlayer(); + if (player) { + player.setPlaybackRate(speed); + } + }, []); + + const getPlayer = useCallback(() => { + return youtubePlayerRef.current?.getPlayer() ?? null; + }, []); + + const handleLoadFromLibrary = (session: SessionData) => { + setYoutubeUrl(session.youtube_url); + setPlayerReady(false); + setCurrentTime(0); + setVideoDuration(0); + setPlaybackSpeed(1); + }; + + const handleSaveSession = async () => { + if (!instrument.trim()) { + setSaveError("Instrument is required"); + return; + } + if (!duration.trim()) { + setSaveError("Duration is required"); + return; + } + + // Validate duration format HH:MM:SS + const durationMatch = duration.match(/^(\d{1,2}):(\d{2}):(\d{2})$/); + if (!durationMatch) { + setSaveError("Duration must be in HH:MM:SS format"); + return; + } + + const token = localStorage.getItem("token"); + if (!token) { + router.push("/login"); + return; + } + + setIsSaving(true); + setSaveError(""); + setSaveSuccess(false); + + try { + const response = await axios.post( + `${apiBaseUrl}/sessions/`, + { + instrument, + description, + duration, + session_date: new Date().toISOString().split("T")[0], + youtube_url: youtubeUrl, + }, + { headers: { Authorization: `Token ${token}` } } + ); + + setSaveSuccess(true); + setInstrument(""); + setDescription(""); + setDuration(""); + + // Add to library if it has a URL + if (youtubeUrl) { + setLibrarySessions((prev) => [response.data, ...prev]); + } + + setTimeout(() => setSaveSuccess(false), 3000); + } catch (error) { + if (axios.isAxiosError(error)) { + const data = error.response?.data; + if (typeof data === "object" && data !== null) { + const messages = Object.entries(data) + .map(([key, val]) => `${key}: ${val}`) + .join(", "); + setSaveError(messages || "Failed to save session"); + } else { + setSaveError("Failed to save session"); + } + } else { + setSaveError("An unexpected error occurred"); + } + } finally { + setIsSaving(false); + } + }; + + return ( +
+
+

+ YouTube Practice +

+

+ Practice along with videos from your library +

+
+ +
+ {/* URL Input */} + + + + + Video Player + + + Paste a YouTube URL or select from your library below + + + +
+ + { + setYoutubeUrl(e.target.value); + setPlayerReady(false); + }} + placeholder="https://www.youtube.com/watch?v=..." + /> +
+ + {/* Player */} + {hasVideo && ( +
+ + + {/* Timestamp */} +
+ + + {formatTimestamp(currentTime)} /{" "} + {formatTimestamp(videoDuration)} + +
+ + {/* Playback Speed */} +
+

Playback Speed

+
+ {SPEEDS.map((speed) => ( + + ))} +
+
+ + {/* A-B Loop */} + +
+ )} +
+
+ + {/* Save to Session */} + {hasVideo && ( + + + + + Save to Session + + + Log this practice session to your history + + + +
+
+ + setInstrument(e.target.value)} + placeholder="e.g., Guitar, Piano" + /> +
+
+ + setDuration(e.target.value)} + placeholder="e.g., 00:30:00" + /> +
+
+
+ + setDescription(e.target.value)} + placeholder="e.g., Working on chord progressions" + /> +
+ + {saveError && ( +

+ {saveError} +

+ )} + {saveSuccess && ( +

+ Session saved successfully! +

+ )} + + +
+
+ )} + + {/* Video Library */} + + + + + Your Practice Videos + + + Sessions with YouTube videos from your practice history + + + + {isLoadingLibrary ? ( +

+ Loading your videos... +

+ ) : librarySessions.length === 0 ? ( +

+ No practice sessions with videos yet. Paste a URL above to get + started! +

+ ) : ( +
+ {librarySessions.map((session) => { + const sessionVideoId = extractVideoId(session.youtube_url); + const isActive = + sessionVideoId && sessionVideoId === videoId; + return ( + + ); + })} +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/next-app/src/components/navigation/Header.tsx b/frontend/next-app/src/components/navigation/Header.tsx index be26bd4..1214bb0 100644 --- a/frontend/next-app/src/components/navigation/Header.tsx +++ b/frontend/next-app/src/components/navigation/Header.tsx @@ -17,6 +17,7 @@ export function Header() { { name: "Profile", href: "/profilepage" }, { name: "Practice Timer", href: "/practice-timer" }, { name: "Recommendations", href: "/recommendations" }, + { name: "YouTube Practice", href: "/youtube-practice" }, ]; // Don't show header on login/register pages diff --git a/frontend/next-app/src/components/navigation/MobileNav.tsx b/frontend/next-app/src/components/navigation/MobileNav.tsx index 245dfef..b98981f 100644 --- a/frontend/next-app/src/components/navigation/MobileNav.tsx +++ b/frontend/next-app/src/components/navigation/MobileNav.tsx @@ -17,6 +17,7 @@ export function MobileNav() { { name: "Profile", href: "/profilepage" }, { name: "Practice Timer", href: "/practice-timer" }, { name: "Recommendations", href: "/recommendations" }, + { name: "YouTube Practice", href: "/youtube-practice" }, ]; // Don't show on login/register pages diff --git a/frontend/next-app/src/components/profile/ProfilePage.tsx b/frontend/next-app/src/components/profile/ProfilePage.tsx index e5d0fc6..b497167 100644 --- a/frontend/next-app/src/components/profile/ProfilePage.tsx +++ b/frontend/next-app/src/components/profile/ProfilePage.tsx @@ -18,6 +18,7 @@ interface Session { duration: string; description: string; session_date: string; + youtube_url?: string; } const ProfilePage = () => { @@ -120,12 +121,13 @@ const ProfilePage = () => { Duration Description Session Date + Video {sessions.length === 0 ? ( - + No sessions yet. Start your first practice session! @@ -139,6 +141,20 @@ const ProfilePage = () => { {session.duration} {session.description} {session.session_date} + + {session.youtube_url ? ( + + View + + ) : ( + -- + )} + ); }) diff --git a/frontend/next-app/src/components/youtube/ABLoopControl.tsx b/frontend/next-app/src/components/youtube/ABLoopControl.tsx new file mode 100644 index 0000000..05d4609 --- /dev/null +++ b/frontend/next-app/src/components/youtube/ABLoopControl.tsx @@ -0,0 +1,115 @@ +"use client"; + +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; + +interface ABLoopControlProps { + getPlayer: () => YT.Player | null; +} + +function formatTimestamp(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; +} + +export default function ABLoopControl({ getPlayer }: ABLoopControlProps) { + const [pointA, setPointA] = useState(null); + const [pointB, setPointB] = useState(null); + const loopIntervalRef = useRef(null); + + const isLooping = pointA !== null && pointB !== null; + + const clearLoop = useCallback(() => { + if (loopIntervalRef.current) { + clearInterval(loopIntervalRef.current); + loopIntervalRef.current = null; + } + }, []); + + // Polling loop: check current time and seek back to A when past B + useEffect(() => { + if (!isLooping) { + clearLoop(); + return; + } + + loopIntervalRef.current = setInterval(() => { + const player = getPlayer(); + if (!player) return; + + const currentTime = player.getCurrentTime(); + if (currentTime >= pointB!) { + player.seekTo(pointA!, true); + } + }, 200); + + return clearLoop; + }, [pointA, pointB, isLooping, getPlayer, clearLoop]); + + // Cleanup on unmount + useEffect(() => { + return clearLoop; + }, [clearLoop]); + + const handleSetA = () => { + const player = getPlayer(); + if (!player) return; + const time = player.getCurrentTime(); + setPointA(time); + // If B is set and now A >= B, clear B + if (pointB !== null && time >= pointB) { + setPointB(null); + } + }; + + const handleSetB = () => { + const player = getPlayer(); + if (!player) return; + const time = player.getCurrentTime(); + if (pointA !== null && time > pointA) { + setPointB(time); + // Immediately seek to A to start the loop + player.seekTo(pointA, true); + } + }; + + const handleClear = () => { + setPointA(null); + setPointB(null); + }; + + return ( +
+

A-B Loop

+
+ + + {pointA !== null && ( + + )} +
+ {isLooping && ( + + Looping {formatTimestamp(pointA!)} → {formatTimestamp(pointB!)} + + )} +
+ ); +} diff --git a/frontend/next-app/src/components/youtube/PlaybackSpeedControl.tsx b/frontend/next-app/src/components/youtube/PlaybackSpeedControl.tsx new file mode 100644 index 0000000..6ac743e --- /dev/null +++ b/frontend/next-app/src/components/youtube/PlaybackSpeedControl.tsx @@ -0,0 +1,35 @@ +"use client"; + +import React from 'react'; +import { Button } from '@/components/ui/button'; + +const SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2]; + +interface PlaybackSpeedControlProps { + currentSpeed: number; + onSpeedChange: (speed: number) => void; +} + +export default function PlaybackSpeedControl({ + currentSpeed, + onSpeedChange, +}: PlaybackSpeedControlProps) { + return ( +
+

Playback Speed

+
+ {SPEEDS.map((speed) => ( + + ))} +
+
+ ); +} diff --git a/frontend/next-app/src/components/youtube/YouTubePlayer.tsx b/frontend/next-app/src/components/youtube/YouTubePlayer.tsx new file mode 100644 index 0000000..e96ec49 --- /dev/null +++ b/frontend/next-app/src/components/youtube/YouTubePlayer.tsx @@ -0,0 +1,123 @@ +"use client"; + +import React, { useEffect, useRef, useCallback, useImperativeHandle, forwardRef } from 'react'; + +export function extractVideoId(url: string): string | null { + if (!url) return null; + try { + const parsed = new URL(url); + if (parsed.hostname === 'youtu.be') { + return parsed.pathname.slice(1).split('/')[0] || null; + } + if (parsed.hostname.includes('youtube.com')) { + const vParam = parsed.searchParams.get('v'); + if (vParam) return vParam; + const embedMatch = parsed.pathname.match(/\/embed\/([a-zA-Z0-9_-]{11})/); + if (embedMatch) return embedMatch[1]; + } + } catch { + const match = url.match(/(?:v=|youtu\.be\/|embed\/)([a-zA-Z0-9_-]{11})/); + return match ? match[1] : null; + } + return null; +} + +export interface YouTubePlayerHandle { + getPlayer: () => YT.Player | null; +} + +interface YouTubePlayerProps { + videoId: string; + onReady?: (player: YT.Player) => void; +} + +const YouTubePlayer = forwardRef( + function YouTubePlayer({ videoId, onReady }, ref) { + const containerRef = useRef(null); + const playerRef = useRef(null); + const prevVideoIdRef = useRef(''); + + useImperativeHandle(ref, () => ({ + getPlayer: () => playerRef.current, + })); + + const createPlayer = useCallback(() => { + if (!containerRef.current || !window.YT?.Player || !videoId) return; + + // Destroy existing player + if (playerRef.current) { + playerRef.current.destroy(); + playerRef.current = null; + } + + // Create a fresh div for the player + const playerDiv = document.createElement('div'); + containerRef.current.innerHTML = ''; + containerRef.current.appendChild(playerDiv); + + playerRef.current = new window.YT.Player(playerDiv, { + videoId, + width: '100%', + height: '100%', + playerVars: { + autoplay: 0, + controls: 1, + rel: 0, + modestbranding: 1, + }, + events: { + onReady: (event) => { + onReady?.(event.target); + }, + }, + }); + + prevVideoIdRef.current = videoId; + }, [videoId, onReady]); + + // Load YouTube IFrame API + useEffect(() => { + if (window.YT?.Player) { + createPlayer(); + return; + } + + const existingScript = document.querySelector( + 'script[src="https://www.youtube.com/iframe_api"]' + ); + + if (!existingScript) { + const script = document.createElement('script'); + script.src = 'https://www.youtube.com/iframe_api'; + document.head.appendChild(script); + } + + window.onYouTubeIframeAPIReady = () => { + createPlayer(); + }; + + return () => { + if (playerRef.current) { + playerRef.current.destroy(); + playerRef.current = null; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Handle video ID changes + useEffect(() => { + if (videoId && videoId !== prevVideoIdRef.current && window.YT?.Player) { + createPlayer(); + } + }, [videoId, createPlayer]); + + return ( +
+
+
+ ); + } +); + +export default YouTubePlayer; diff --git a/frontend/next-app/src/types/youtube.d.ts b/frontend/next-app/src/types/youtube.d.ts new file mode 100644 index 0000000..19e6b28 --- /dev/null +++ b/frontend/next-app/src/types/youtube.d.ts @@ -0,0 +1,60 @@ +declare namespace YT { + class Player { + constructor(elementId: string | HTMLElement, options: PlayerOptions); + playVideo(): void; + pauseVideo(): void; + seekTo(seconds: number, allowSeekAhead: boolean): void; + getCurrentTime(): number; + getDuration(): number; + setPlaybackRate(rate: number): void; + getAvailablePlaybackRates(): number[]; + getPlayerState(): number; + destroy(): void; + } + + interface PlayerOptions { + height?: string | number; + width?: string | number; + videoId?: string; + playerVars?: { + autoplay?: 0 | 1; + controls?: 0 | 1; + rel?: 0 | 1; + modestbranding?: 0 | 1; + [key: string]: unknown; + }; + events?: { + onReady?: (event: PlayerEvent) => void; + onStateChange?: (event: OnStateChangeEvent) => void; + onError?: (event: OnErrorEvent) => void; + }; + } + + interface PlayerEvent { + target: Player; + } + + interface OnStateChangeEvent { + data: number; + target: Player; + } + + interface OnErrorEvent { + data: number; + target: Player; + } + + const PlayerState: { + UNSTARTED: -1; + ENDED: 0; + PLAYING: 1; + PAUSED: 2; + BUFFERING: 3; + CUED: 5; + }; +} + +interface Window { + onYouTubeIframeAPIReady?: () => void; + YT?: typeof YT; +} diff --git a/session/migrations/0008_session_youtube_url.py b/session/migrations/0008_session_youtube_url.py new file mode 100644 index 0000000..4dde78d --- /dev/null +++ b/session/migrations/0008_session_youtube_url.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.4 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('session', '0007_session_is_paused_session_paused_at'), + ] + + operations = [ + migrations.AddField( + model_name='session', + name='youtube_url', + field=models.URLField(blank=True, default='', max_length=500), + ), + ] diff --git a/session/models.py b/session/models.py index 1f2b1ad..7fee5fc 100644 --- a/session/models.py +++ b/session/models.py @@ -44,6 +44,7 @@ class Session(models.Model): skill_level = models.CharField(max_length=20, choices=SKILL_LEVEL_CHOICES, default="Instrument Choice") instrument_preference = models.CharField(max_length=20, choices=INSTRUMENT_CHOICES, default="Instrument Choice") goals = models.TextField(default='Insert Goal') + youtube_url = models.URLField(max_length=500, blank=True, default='') created_at = models.DateTimeField(default=timezone.now) updated_at = models.DateTimeField(default=timezone.now) diff --git a/session/serializers.py b/session/serializers.py index 60d5c02..d8e1ad2 100644 --- a/session/serializers.py +++ b/session/serializers.py @@ -36,6 +36,7 @@ class Meta: "paused_duration", "paused_at", "is_paused", + "youtube_url", ) model = Session read_only_fields = ['display_id', 'user'] diff --git a/session/tests.py b/session/tests.py index f5e5816..ccd2d0a 100644 --- a/session/tests.py +++ b/session/tests.py @@ -75,6 +75,27 @@ def test_session_string_representation(self): ) self.assertEqual(str(session), "Session on 15-03-2024") + def test_session_with_youtube_url(self): + """Test session creation with YouTube URL""" + session = Session.objects.create( + user=self.user, + instrument="guitar", + duration=timedelta(minutes=30), + session_date=date.today(), + youtube_url="https://www.youtube.com/watch?v=dQw4w9WgXcQ" + ) + self.assertEqual(session.youtube_url, "https://www.youtube.com/watch?v=dQw4w9WgXcQ") + + def test_session_youtube_url_defaults_to_empty(self): + """Test that youtube_url defaults to empty string""" + session = Session.objects.create( + user=self.user, + instrument="guitar", + duration=timedelta(minutes=30), + session_date=date.today() + ) + self.assertEqual(session.youtube_url, '') + def test_session_with_tags(self): """Test session with tags relationship""" tag1 = Tag.objects.create(name="scales", user=self.user) @@ -219,6 +240,29 @@ def test_delete_session(self): self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual(Session.objects.count(), 0) + def test_update_youtube_url_mid_session(self): + """Test adding a YouTube URL to an existing session via PATCH""" + session = Session.objects.create( + user=self.user, + instrument='guitar', + duration=timedelta(0), + session_date=date.today(), + display_id=1, + in_progress=True, + started_at=timezone.now() + ) + + url = reverse('session_detail', args=[session.session_id]) + response = self.client.patch( + url, + {'youtube_url': 'https://www.youtube.com/watch?v=abc123def45'}, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + session.refresh_from_db() + self.assertEqual(session.youtube_url, 'https://www.youtube.com/watch?v=abc123def45') + def test_unauthenticated_access(self): """Test that unauthenticated users cannot access sessions""" self.client.credentials() # Remove authentication @@ -256,6 +300,28 @@ def test_start_timer(self): self.assertIsNotNone(response.data['started_at']) self.assertEqual(response.data['instrument'], 'guitar') + def test_start_timer_with_youtube_url(self): + """Test starting a timer with a YouTube URL""" + url = reverse('start-timer') + data = { + 'instrument': 'guitar', + 'description': 'learning a lick', + 'youtube_url': 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' + } + response = self.client.post(url, data, format='json') + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['youtube_url'], 'https://www.youtube.com/watch?v=dQw4w9WgXcQ') + + def test_start_timer_without_youtube_url(self): + """Test starting a timer without YouTube URL defaults to empty""" + url = reverse('start-timer') + data = {'instrument': 'guitar'} + response = self.client.post(url, data, format='json') + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['youtube_url'], '') + def test_start_timer_without_instrument(self): """Test that starting timer requires instrument""" url = reverse('start-timer') diff --git a/session/views.py b/session/views.py index 3223ac4..6a0caa9 100644 --- a/session/views.py +++ b/session/views.py @@ -243,6 +243,7 @@ def start_timer(request): """Start a new practice session timer""" instrument = request.data.get('instrument') description = request.data.get('description', '') + youtube_url = request.data.get('youtube_url', '') if not instrument: return Response({'error': 'Instrument is required'}, status=status.HTTP_400_BAD_REQUEST) @@ -259,6 +260,7 @@ def start_timer(request): user=request.user, instrument=instrument, description=description, + youtube_url=youtube_url, session_date=timezone.now().date(), duration=timedelta(0), in_progress=True, From 53bf087c810cd4de995a6a47f483b1187a078559 Mon Sep 17 00:00:00 2001 From: Daniel Adekugbe Date: Sun, 1 Mar 2026 09:33:39 +0000 Subject: [PATCH 04/14] Fix CI test failures and add accounts test coverage - Disable SECURE_SSL_REDIRECT in CI to prevent 301 redirects on all backend API tests (configurable via env var, defaults to True in prod) - Fix frontend test assertion to include youtube_url in POST body - Add accounts app tests for CustomUser model, current-user and logout endpoints Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test-and-deploy.yml | 1 + accounts/tests.py | 112 +++++++++++++++++- django_project/settings.py | 4 +- .../practice-timer/__tests__/page.test.tsx | 2 +- 4 files changed, 115 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-and-deploy.yml b/.github/workflows/test-and-deploy.yml index dcc4b99..537e168 100644 --- a/.github/workflows/test-and-deploy.yml +++ b/.github/workflows/test-and-deploy.yml @@ -40,6 +40,7 @@ jobs: - name: Run Django tests env: DATABASE_URL: postgresql://postgres@localhost:5432/postgres + SECURE_SSL_REDIRECT: "False" run: | python manage.py test diff --git a/accounts/tests.py b/accounts/tests.py index 7ce503c..3d4aadb 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -1,3 +1,113 @@ +from django.contrib.auth import get_user_model from django.test import TestCase +from rest_framework.test import APITestCase, APIClient +from rest_framework import status +from rest_framework.authtoken.models import Token -# Create your tests here. + +class CustomUserModelTests(TestCase): + """Test cases for the CustomUser model""" + + def test_create_user(self): + """Test creating a user with custom name field""" + user = get_user_model().objects.create_user( + username="testuser", + email="test@email.com", + password="testpass123", + name="Test User" + ) + self.assertEqual(user.username, "testuser") + self.assertEqual(user.email, "test@email.com") + self.assertEqual(user.name, "Test User") + self.assertTrue(user.check_password("testpass123")) + self.assertFalse(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_create_user_without_name(self): + """Test that name field is optional""" + user = get_user_model().objects.create_user( + username="testuser", + email="test@email.com", + password="testpass123" + ) + self.assertIsNone(user.name) + + def test_create_superuser(self): + """Test creating a superuser""" + user = get_user_model().objects.create_superuser( + username="admin", + email="admin@email.com", + password="adminpass123" + ) + self.assertTrue(user.is_staff) + self.assertTrue(user.is_superuser) + + +class CurrentUserViewTests(APITestCase): + """Test cases for current_user_view endpoint""" + + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + username="testuser", + email="test@email.com", + password="testpass123", + name="Test User" + ) + self.token = Token.objects.create(user=self.user) + self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) + + def test_get_current_user(self): + """Test getting current authenticated user data""" + response = self.client.get('/api/v1/current-user/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['username'], 'testuser') + self.assertEqual(response.data['email'], 'test@email.com') + self.assertEqual(response.data['name'], 'Test User') + + def test_get_current_user_unauthenticated(self): + """Test that unauthenticated users cannot access current user endpoint""" + self.client.credentials() + response = self.client.get('/api/v1/current-user/') + self.assertIn(response.status_code, [ + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN + ]) + + def test_current_user_returns_correct_fields(self): + """Test that serializer returns expected fields""" + response = self.client.get('/api/v1/current-user/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('id', response.data) + self.assertIn('username', response.data) + self.assertIn('name', response.data) + self.assertIn('email', response.data) + + +class LogoutViewTests(APITestCase): + """Test cases for logout_view endpoint""" + + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + username="testuser", + email="test@email.com", + password="testpass123" + ) + self.token = Token.objects.create(user=self.user) + self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) + + def test_logout_deletes_token(self): + """Test that logout deletes the auth token""" + response = self.client.post('/api/v1/logout/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(Token.objects.filter(user=self.user).exists()) + + def test_logout_unauthenticated(self): + """Test that unauthenticated users cannot logout""" + self.client.credentials() + response = self.client.post('/api/v1/logout/') + self.assertIn(response.status_code, [ + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN + ]) diff --git a/django_project/settings.py b/django_project/settings.py index d10d303..cf78e81 100644 --- a/django_project/settings.py +++ b/django_project/settings.py @@ -208,8 +208,8 @@ # Security settings for production if not DEBUG: - # Force HTTPS in production - SECURE_SSL_REDIRECT = True + # Force HTTPS in production (can be disabled for CI testing via env var) + SECURE_SSL_REDIRECT = os.getenv("SECURE_SSL_REDIRECT", "True") == "True" SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True SECURE_BROWSER_XSS_FILTER = True diff --git a/frontend/next-app/src/app/practice-timer/__tests__/page.test.tsx b/frontend/next-app/src/app/practice-timer/__tests__/page.test.tsx index bd88c4b..ffa12fe 100644 --- a/frontend/next-app/src/app/practice-timer/__tests__/page.test.tsx +++ b/frontend/next-app/src/app/practice-timer/__tests__/page.test.tsx @@ -115,7 +115,7 @@ describe('PracticeTimerPage', () => { await waitFor(() => { expect(mockedAxios.post).toHaveBeenCalledWith( 'http://localhost:8000/api/v1/timer/start/', - { instrument: 'Piano', description: '' }, + { instrument: 'Piano', description: '', youtube_url: '' }, expect.objectContaining({ headers: { 'Authorization': 'Token test-token' } }) From eccadb4076a513fecf2595cf8b3c12d51894a79f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 10:21:05 +0000 Subject: [PATCH 05/14] Fix critical bugs and add production-readiness features - Fix YouTube Practice page API endpoint mismatch (was calling /sessions/ instead of /) - Remove window.location.reload() calls in PracticeSessionForm (use state updates instead) - Remove debug print statements from accounts/views.py - Add login/register cross-navigation links and forgot password page - Add error boundary component wrapping all pages - Add loading skeletons for dashboard, profile, and YouTube practice pages - Add welcome banner empty state for new users on dashboard - Handle missing OpenAI API key gracefully with 503 response - Add timer completion sound notification using Web Audio API - Add password reset flow (frontend page + dj-rest-auth integration) - Add profile editing (name/email) with backend endpoint - Add session filtering, search, and pagination on profile page - Add frontend tests for LoginPage, RegisterPage, Dashboard, and ErrorBoundary https://claude.ai/code/session_01KeKHEPaYoNsosovTEZKQQC --- accounts/serializers.py | 6 + accounts/urls.py | 4 +- accounts/views.py | 16 +- .../src/app/dashboard/__tests__/page.test.tsx | 98 ++++++ frontend/next-app/src/app/dashboard/page.tsx | 32 +- .../next-app/src/app/forgot-password/page.tsx | 114 +++++++ frontend/next-app/src/app/layout.tsx | 5 +- .../next-app/src/app/practice-timer/page.tsx | 50 +++ .../src/app/youtube-practice/page.tsx | 16 +- .../src/components/auth/LoginPage.tsx | 19 +- .../src/components/auth/RegisterPage.tsx | 10 + .../auth/__tests__/LoginPage.test.tsx | 95 ++++++ .../auth/__tests__/RegisterPage.test.tsx | 110 +++++++ .../practice/PracticeSessionForm.tsx | 2 - .../src/components/profile/ProfilePage.tsx | 303 +++++++++++++++--- .../ui/__tests__/error-boundary.test.tsx | 74 +++++ .../src/components/ui/error-boundary.tsx | 62 ++++ .../next-app/src/components/ui/skeleton.tsx | 15 + session/views.py | 9 +- 19 files changed, 965 insertions(+), 75 deletions(-) create mode 100644 frontend/next-app/src/app/dashboard/__tests__/page.test.tsx create mode 100644 frontend/next-app/src/app/forgot-password/page.tsx create mode 100644 frontend/next-app/src/components/auth/__tests__/LoginPage.test.tsx create mode 100644 frontend/next-app/src/components/auth/__tests__/RegisterPage.test.tsx create mode 100644 frontend/next-app/src/components/ui/__tests__/error-boundary.test.tsx create mode 100644 frontend/next-app/src/components/ui/error-boundary.tsx create mode 100644 frontend/next-app/src/components/ui/skeleton.tsx diff --git a/accounts/serializers.py b/accounts/serializers.py index ad5495c..19a6b2b 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -5,3 +5,9 @@ class CustomUserSerializer(serializers.ModelSerializer): class Meta: model = CustomUser fields = ('id', 'username', 'name', 'email',) + + +class ProfileUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = CustomUser + fields = ('name', 'email',) diff --git a/accounts/urls.py b/accounts/urls.py index 5180e3c..8dd3530 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -1,10 +1,10 @@ from django.urls import path -from .views import current_user_view, logout_view - +from .views import current_user_view, update_profile_view, logout_view urlpatterns = [ path('api/v1/current-user/', current_user_view, name='current-user'), + path('api/v1/update-profile/', update_profile_view, name='update-profile'), path('api/v1/logout/', logout_view, name='logout'), ] diff --git a/accounts/views.py b/accounts/views.py index a9bfe59..3d0b528 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -2,17 +2,27 @@ from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from .serializers import CustomUserSerializer +from .serializers import CustomUserSerializer, ProfileUpdateSerializer @api_view(['GET']) @permission_classes([IsAuthenticated]) def current_user_view(request): - print("User:", request.user) # Print the user's information - print("Request data:", request.data) # Print the request data (may be empty for GET requests) serializer = CustomUserSerializer(request.user) return Response(serializer.data) +@api_view(['PUT', 'PATCH']) +@permission_classes([IsAuthenticated]) +def update_profile_view(request): + serializer = ProfileUpdateSerializer( + request.user, data=request.data, partial=True + ) + if serializer.is_valid(): + serializer.save() + return Response(CustomUserSerializer(request.user).data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @api_view(['POST']) @permission_classes([IsAuthenticated]) def logout_view(request): diff --git a/frontend/next-app/src/app/dashboard/__tests__/page.test.tsx b/frontend/next-app/src/app/dashboard/__tests__/page.test.tsx new file mode 100644 index 0000000..b4a0722 --- /dev/null +++ b/frontend/next-app/src/app/dashboard/__tests__/page.test.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import axios from 'axios'; +import DashboardPage from '../page'; + +jest.mock('axios', () => ({ + get: jest.fn(), + isAxiosError: jest.fn(), +})); +const mockedAxios = axios as jest.Mocked; + +const mockPush = jest.fn(); +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})); + +const mockStats = { + total_hours: 12.5, + total_sessions: 25, + week_hours: 3.2, + current_streak: 5, + favorite_instrument: 'Guitar', +}; + +describe('DashboardPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + localStorage.clear(); + localStorage.setItem('token', 'test-token'); + }); + + it('shows loading skeleton initially', () => { + mockedAxios.get.mockImplementation(() => new Promise(() => {})); + render(); + // Should show skeleton elements (animate-pulse) + const skeletons = document.querySelectorAll('.animate-pulse'); + expect(skeletons.length).toBeGreaterThan(0); + }); + + it('renders dashboard with stats', async () => { + mockedAxios.get.mockResolvedValueOnce({ data: mockStats }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Dashboard')).toBeInTheDocument(); + expect(screen.getByText('12.5')).toBeInTheDocument(); + expect(screen.getByText('3.2h')).toBeInTheDocument(); + expect(screen.getByText('5 days')).toBeInTheDocument(); + expect(screen.getByText('Guitar')).toBeInTheDocument(); + }); + }); + + it('shows welcome banner for new users with no sessions', async () => { + mockedAxios.get.mockResolvedValueOnce({ + data: { ...mockStats, total_sessions: 0, total_hours: 0, week_hours: 0, current_streak: 0 }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/welcome to your practice journey/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /start your first session/i })).toBeInTheDocument(); + }); + }); + + it('shows streak banner when streak is active', async () => { + mockedAxios.get.mockResolvedValueOnce({ data: mockStats }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/5 day streak/i)).toBeInTheDocument(); + }); + }); + + it('shows error state on API failure', async () => { + mockedAxios.get.mockRejectedValueOnce(new Error('Network error')); + + render(); + + await waitFor(() => { + expect(screen.getByText(/failed to load stats/i)).toBeInTheDocument(); + }); + }); + + it('renders quick action cards', async () => { + mockedAxios.get.mockResolvedValueOnce({ data: mockStats }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Start Practice Session')).toBeInTheDocument(); + expect(screen.getByText('View All Sessions')).toBeInTheDocument(); + expect(screen.getByText('Practice Recommendations')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/next-app/src/app/dashboard/page.tsx b/frontend/next-app/src/app/dashboard/page.tsx index 0d8defe..f6c48d0 100644 --- a/frontend/next-app/src/app/dashboard/page.tsx +++ b/frontend/next-app/src/app/dashboard/page.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect } from 'react'; import axios from 'axios'; import { StatsCard } from '@/components/dashboard/StatsCard'; +import { Button } from '@/components/ui/button'; import { Clock, TrendingUp, Flame, Music } from 'lucide-react'; import { useRouter } from 'next/navigation'; @@ -48,10 +49,19 @@ export default function DashboardPage() { if (isLoading) { return ( -
-
-
-

Loading your dashboard...

+
+
+
+
+
+
+ {[...Array(4)].map((_, i) => ( +
+
+
+
+
+ ))}
); @@ -107,6 +117,20 @@ export default function DashboardPage() { />
+ {/* Welcome Banner for new users */} + {stats.total_sessions === 0 && ( +
+

Welcome to your practice journey!

+

+ Start your first practice session to begin tracking your progress. + Use the timer, log your sessions, and watch your skills grow over time. +

+ +
+ )} + {/* Quick Actions Section */}
{ + e.preventDefault(); + setIsLoading(true); + setError(""); + + try { + await axios.post(`${apiBaseUrl}/dj-rest-auth/password/reset/`, { + email, + }); + setSuccess(true); + } catch (err) { + if (axios.isAxiosError(err) && err.response?.data) { + const data = err.response.data; + if (typeof data === "object") { + const messages = Object.values(data).flat().join(" "); + setError(messages || "Failed to send reset email."); + } else { + setError("Failed to send reset email."); + } + } else { + setError("An unexpected error occurred."); + } + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + + Reset Password + + Enter your email address and we'll send you a link to reset + your password. + + + + {success ? ( +
+

+ If an account exists with that email, a password reset link has + been sent. Please check your inbox. +

+ +
+ ) : ( +
+ {error && ( +
+ {error} +
+ )} +
+ + setEmail(e.target.value)} + placeholder="your@email.com" + required + disabled={isLoading} + /> +
+ +
+ )} +
+ +

+ Remember your password?{" "} + + Log in + +

+
+
+
+ ); +} diff --git a/frontend/next-app/src/app/layout.tsx b/frontend/next-app/src/app/layout.tsx index dd99b2e..4fcd296 100644 --- a/frontend/next-app/src/app/layout.tsx +++ b/frontend/next-app/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { ThemeProvider } from "@/providers/theme-provider"; import { Header } from "@/components/navigation/Header"; +import { ErrorBoundary } from "@/components/ui/error-boundary"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -37,7 +38,9 @@ export default function RootLayout({ >
- {children} + + {children} +
diff --git a/frontend/next-app/src/app/practice-timer/page.tsx b/frontend/next-app/src/app/practice-timer/page.tsx index 849b429..7f419e1 100644 --- a/frontend/next-app/src/app/practice-timer/page.tsx +++ b/frontend/next-app/src/app/practice-timer/page.tsx @@ -34,6 +34,13 @@ export default function PracticeTimerPage() { const videoId = extractVideoId(youtubeUrl); const hasVideo = Boolean(videoId); + useEffect(() => { + // Request notification permission + if ('Notification' in window && Notification.permission === 'default') { + Notification.requestPermission(); + } + }, []); + useEffect(() => { // Check if there's an active timer const checkActiveTimer = async () => { @@ -127,6 +134,46 @@ export default function PracticeTimerPage() { } }; + const playCompletionSound = () => { + try { + const audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + oscillator.frequency.value = 800; + oscillator.type = 'sine'; + gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.8); + oscillator.start(audioContext.currentTime); + oscillator.stop(audioContext.currentTime + 0.8); + + // Second beep + const osc2 = audioContext.createOscillator(); + const gain2 = audioContext.createGain(); + osc2.connect(gain2); + gain2.connect(audioContext.destination); + osc2.frequency.value = 1000; + osc2.type = 'sine'; + gain2.gain.setValueAtTime(0.3, audioContext.currentTime + 0.3); + gain2.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 1.1); + osc2.start(audioContext.currentTime + 0.3); + osc2.stop(audioContext.currentTime + 1.1); + } catch { + // Audio not supported, silent fallback + } + }; + + const notifySessionComplete = (duration: string) => { + playCompletionSound(); + + if ('Notification' in window && Notification.permission === 'granted') { + new Notification('Practice Session Complete!', { + body: `Great work! You practiced ${instrument} for ${duration}.`, + }); + } + }; + const handleStop = async () => { if (!sessionId) return; @@ -140,6 +187,9 @@ export default function PracticeTimerPage() { { headers: { 'Authorization': `Token ${token}` } } ); + const completedDuration = formatTime(elapsedSeconds); + notifySessionComplete(completedDuration); + // Clean up YouTube player const player = youtubePlayerRef.current?.getPlayer(); if (player) { diff --git a/frontend/next-app/src/app/youtube-practice/page.tsx b/frontend/next-app/src/app/youtube-practice/page.tsx index d555618..6b69b0d 100644 --- a/frontend/next-app/src/app/youtube-practice/page.tsx +++ b/frontend/next-app/src/app/youtube-practice/page.tsx @@ -77,7 +77,7 @@ export default function YouTubePracticePage() { } try { - const response = await axios.get(`${apiBaseUrl}/sessions/`, { + const response = await axios.get(`${apiBaseUrl}/`, { headers: { Authorization: `Token ${token}` }, }); const sessionsWithVideo = response.data.filter( @@ -173,7 +173,7 @@ export default function YouTubePracticePage() { try { const response = await axios.post( - `${apiBaseUrl}/sessions/`, + `${apiBaseUrl}/`, { instrument, description, @@ -379,9 +379,15 @@ export default function YouTubePracticePage() { {isLoadingLibrary ? ( -

- Loading your videos... -

+
+ {[...Array(3)].map((_, i) => ( +
+
+
+
+
+ ))} +
) : librarySessions.length === 0 ? (

No practice sessions with videos yet. Paste a URL above to get diff --git a/frontend/next-app/src/components/auth/LoginPage.tsx b/frontend/next-app/src/components/auth/LoginPage.tsx index 08a98cb..2b56cf8 100644 --- a/frontend/next-app/src/components/auth/LoginPage.tsx +++ b/frontend/next-app/src/components/auth/LoginPage.tsx @@ -3,6 +3,7 @@ import React, { useState } from 'react'; import axios, { AxiosError } from 'axios'; import { useRouter } from 'next/navigation'; +import Link from 'next/link'; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -10,6 +11,7 @@ import { Card, CardContent, CardDescription, + CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; @@ -108,12 +110,17 @@ const LoginPage = () => { - {/* Optional CardFooter for links like 'Forgot password?' or 'Sign up' */} - {/* - - */} + + + Forgot your password? + +

+ Don't have an account?{' '} + + Sign up + +

+
); diff --git a/frontend/next-app/src/components/auth/RegisterPage.tsx b/frontend/next-app/src/components/auth/RegisterPage.tsx index f50d9bd..33641b4 100644 --- a/frontend/next-app/src/components/auth/RegisterPage.tsx +++ b/frontend/next-app/src/components/auth/RegisterPage.tsx @@ -3,6 +3,7 @@ import React, { useState } from 'react'; import axios, { AxiosError } from 'axios'; import { useRouter } from 'next/navigation'; +import Link from 'next/link'; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -10,6 +11,7 @@ import { Card, CardContent, CardDescription, + CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; @@ -152,6 +154,14 @@ const RegisterPage = () => { + +

+ Already have an account?{' '} + + Log in + +

+
); diff --git a/frontend/next-app/src/components/auth/__tests__/LoginPage.test.tsx b/frontend/next-app/src/components/auth/__tests__/LoginPage.test.tsx new file mode 100644 index 0000000..8366994 --- /dev/null +++ b/frontend/next-app/src/components/auth/__tests__/LoginPage.test.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import axios from 'axios'; +import LoginPage from '../LoginPage'; + +jest.mock('axios', () => ({ + post: jest.fn(), + isAxiosError: jest.fn(), +})); +const mockedAxios = axios as jest.Mocked; + +const mockPush = jest.fn(); +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})); + +describe('LoginPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders login form', () => { + render(); + expect(screen.getByLabelText(/username/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument(); + }); + + it('shows link to register page', () => { + render(); + expect(screen.getByText(/sign up/i)).toBeInTheDocument(); + }); + + it('shows link to forgot password', () => { + render(); + expect(screen.getByText(/forgot your password/i)).toBeInTheDocument(); + }); + + it('submits form and redirects on success', async () => { + mockedAxios.post.mockResolvedValueOnce({ + data: { key: 'test-token', user: 1 }, + }); + + const user = userEvent.setup({ delay: null }); + render(); + + await user.type(screen.getByLabelText(/username/i), 'testuser'); + await user.type(screen.getByLabelText(/password/i), 'password123'); + await user.click(screen.getByRole('button', { name: /login/i })); + + await waitFor(() => { + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.stringContaining('/dj-rest-auth/login/'), + { username: 'testuser', password: 'password123' } + ); + expect(mockPush).toHaveBeenCalledWith('/profilepage'); + }); + }); + + it('shows error on failed login', async () => { + const axiosError = { + response: { data: { detail: 'Unable to log in with provided credentials.' } }, + isAxiosError: true, + }; + mockedAxios.post.mockRejectedValueOnce(axiosError); + (mockedAxios.isAxiosError as unknown as jest.Mock).mockReturnValue(true); + + const user = userEvent.setup({ delay: null }); + render(); + + await user.type(screen.getByLabelText(/username/i), 'wrong'); + await user.type(screen.getByLabelText(/password/i), 'wrong'); + await user.click(screen.getByRole('button', { name: /login/i })); + + await waitFor(() => { + expect(screen.getByText(/unable to log in/i)).toBeInTheDocument(); + }); + }); + + it('shows loading state while submitting', async () => { + mockedAxios.post.mockImplementation(() => new Promise(() => {})); + + const user = userEvent.setup({ delay: null }); + render(); + + await user.type(screen.getByLabelText(/username/i), 'testuser'); + await user.type(screen.getByLabelText(/password/i), 'password123'); + await user.click(screen.getByRole('button', { name: /login/i })); + + await waitFor(() => { + expect(screen.getByText(/logging in/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/next-app/src/components/auth/__tests__/RegisterPage.test.tsx b/frontend/next-app/src/components/auth/__tests__/RegisterPage.test.tsx new file mode 100644 index 0000000..cf72f47 --- /dev/null +++ b/frontend/next-app/src/components/auth/__tests__/RegisterPage.test.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import axios from 'axios'; +import RegisterPage from '../RegisterPage'; + +jest.mock('axios', () => ({ + post: jest.fn(), + isAxiosError: jest.fn(), +})); +const mockedAxios = axios as jest.Mocked; + +const mockPush = jest.fn(); +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})); + +describe('RegisterPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders registration form', () => { + render(); + expect(screen.getByLabelText(/username/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + expect(screen.getByLabelText('Password')).toBeInTheDocument(); + expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /register/i })).toBeInTheDocument(); + }); + + it('shows link to login page', () => { + render(); + expect(screen.getByText(/log in/i)).toBeInTheDocument(); + }); + + it('validates email format', async () => { + const user = userEvent.setup({ delay: null }); + render(); + + await user.type(screen.getByLabelText(/username/i), 'testuser'); + await user.type(screen.getByLabelText(/email/i), 'invalidemail'); + await user.type(screen.getByLabelText('Password'), 'password123'); + await user.type(screen.getByLabelText(/confirm password/i), 'password123'); + await user.click(screen.getByRole('button', { name: /register/i })); + + await waitFor(() => { + expect(screen.getByText(/email is not valid/i)).toBeInTheDocument(); + }); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + + it('validates password match', async () => { + const user = userEvent.setup({ delay: null }); + render(); + + await user.type(screen.getByLabelText(/username/i), 'testuser'); + await user.type(screen.getByLabelText(/email/i), 'test@email.com'); + await user.type(screen.getByLabelText('Password'), 'password123'); + await user.type(screen.getByLabelText(/confirm password/i), 'different'); + await user.click(screen.getByRole('button', { name: /register/i })); + + await waitFor(() => { + expect(screen.getByText(/passwords do not match/i)).toBeInTheDocument(); + }); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + + it('validates password length', async () => { + const user = userEvent.setup({ delay: null }); + render(); + + await user.type(screen.getByLabelText(/username/i), 'testuser'); + await user.type(screen.getByLabelText(/email/i), 'test@email.com'); + await user.type(screen.getByLabelText('Password'), 'short'); + await user.type(screen.getByLabelText(/confirm password/i), 'short'); + await user.click(screen.getByRole('button', { name: /register/i })); + + await waitFor(() => { + expect(screen.getByText(/password must be at least 6 characters/i)).toBeInTheDocument(); + }); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + + it('submits form and redirects on success', async () => { + mockedAxios.post.mockResolvedValueOnce({ data: { key: 'token' } }); + + const user = userEvent.setup({ delay: null }); + render(); + + await user.type(screen.getByLabelText(/username/i), 'testuser'); + await user.type(screen.getByLabelText(/email/i), 'test@email.com'); + await user.type(screen.getByLabelText('Password'), 'password123'); + await user.type(screen.getByLabelText(/confirm password/i), 'password123'); + await user.click(screen.getByRole('button', { name: /register/i })); + + await waitFor(() => { + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.stringContaining('/dj-rest-auth/registration/'), + expect.objectContaining({ + username: 'testuser', + email: 'test@email.com', + password1: 'password123', + password2: 'password123', + }) + ); + expect(mockPush).toHaveBeenCalledWith('/login'); + }); + }); +}); diff --git a/frontend/next-app/src/components/practice/PracticeSessionForm.tsx b/frontend/next-app/src/components/practice/PracticeSessionForm.tsx index 175dcbf..4cdc3e4 100644 --- a/frontend/next-app/src/components/practice/PracticeSessionForm.tsx +++ b/frontend/next-app/src/components/practice/PracticeSessionForm.tsx @@ -137,7 +137,6 @@ const PracticeSessionForm: React.FC = ({ practiceSessi }); setSelectedTags([]); setSelectedSessionIdForUpdate(''); - window.location.reload(); } catch (err) { if (axios.isAxiosError(err)) { setError('Error: ' + err.response?.data?.message || err.message); @@ -167,7 +166,6 @@ const PracticeSessionForm: React.FC = ({ practiceSessi setAllSessions(prev => prev.filter(s => s.session_id !== selectedSessionIdForDeletion)); setPracticeSessions(prev => prev.filter(s => s.session_id !== selectedSessionIdForDeletion)); setSelectedSessionIdForDeletion(''); - window.location.reload(); } catch (error) { setError('Failed to delete the session'); } finally { diff --git a/frontend/next-app/src/components/profile/ProfilePage.tsx b/frontend/next-app/src/components/profile/ProfilePage.tsx index b497167..d5f3c3c 100644 --- a/frontend/next-app/src/components/profile/ProfilePage.tsx +++ b/frontend/next-app/src/components/profile/ProfilePage.tsx @@ -9,6 +9,9 @@ import LogoutButton from '../practice/LogoutButton'; import { PracticeCalendarHeatmap } from '../charts/CalendarHeatmap'; import { InstrumentBreakdown } from '../charts/InstrumentBreakdown'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; interface Session { session_id: number; @@ -23,10 +26,21 @@ interface Session { const ProfilePage = () => { const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [name, setName] = useState(''); + const [isEditingProfile, setIsEditingProfile] = useState(false); + const [editName, setEditName] = useState(''); + const [editEmail, setEditEmail] = useState(''); + const [profileSaveError, setProfileSaveError] = useState(''); + const [profileSaveSuccess, setProfileSaveSuccess] = useState(false); const [practiceSessions, setPracticeSessions] = useState([]); const [sessions, setSessions] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + const [instrumentFilter, setInstrumentFilter] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const sessionsPerPage = 10; const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1'; @@ -45,6 +59,8 @@ const ProfilePage = () => { headers: { 'Authorization': `Token ${token}` } }); setUsername(userResponse.data.username); + setEmail(userResponse.data.email || ''); + setName(userResponse.data.name || ''); // Fetch sessions const sessionResponse = await axios.get(`${apiBaseUrl}/`, { @@ -62,7 +78,29 @@ const ProfilePage = () => { }, []); if (isLoading) { - return
Loading...
; + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); } if (error) { @@ -75,12 +113,98 @@ const ProfilePage = () => {
-

Welcome, {username}!

+

Welcome, {name || username}!

Track your musical journey

- +
+ + +
+ {isEditingProfile && ( + + + Edit Profile + + +
{ + e.preventDefault(); + setProfileSaveError(''); + setProfileSaveSuccess(false); + const token = localStorage.getItem('token'); + try { + const response = await axios.patch( + `${apiBaseUrl}/update-profile/`, + { name: editName, email: editEmail }, + { headers: { Authorization: `Token ${token}` } } + ); + setName(response.data.name || ''); + setEmail(response.data.email || ''); + setUsername(response.data.username); + setProfileSaveSuccess(true); + setTimeout(() => { + setProfileSaveSuccess(false); + setIsEditingProfile(false); + }, 1500); + } catch (err) { + if (axios.isAxiosError(err) && err.response?.data) { + const data = err.response.data; + const messages = Object.entries(data) + .map(([key, val]) => `${key}: ${val}`) + .join(', '); + setProfileSaveError(messages || 'Failed to update profile'); + } else { + setProfileSaveError('Failed to update profile'); + } + } + }} + className="space-y-4 max-w-md" + > +
+ + setEditName(e.target.value)} + placeholder="Your display name" + /> +
+
+ + setEditEmail(e.target.value)} + placeholder="your@email.com" + /> +
+ {profileSaveError && ( +

{profileSaveError}

+ )} + {profileSaveSuccess && ( +

Profile updated!

+ )} + +
+
+
+ )} + {/* Charts Grid */}
{/* Calendar Heatmap */} @@ -112,56 +236,133 @@ const ProfilePage = () => {

Your Sessions

-
- - - - - - - - - - - - - {sessions.length === 0 ? ( - - - - ) : ( - sessions.map((session, index) => { - const displayNumber = index + 1; - return ( - - - - - - - + {/* Search and Filter Controls */} + {sessions.length > 0 && ( +
+ { setSearchQuery(e.target.value); setCurrentPage(1); }} + className="sm:max-w-xs" + /> + +
+ )} + + {(() => { + const filteredSessions = sessions.filter(session => { + const matchesSearch = !searchQuery || + session.description.toLowerCase().includes(searchQuery.toLowerCase()) || + session.instrument.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesInstrument = !instrumentFilter || session.instrument === instrumentFilter; + return matchesSearch && matchesInstrument; + }); + + const totalPages = Math.ceil(filteredSessions.length / sessionsPerPage); + const startIndex = (currentPage - 1) * sessionsPerPage; + const paginatedSessions = filteredSessions.slice(startIndex, startIndex + sessionsPerPage); + + return ( + <> +
+
Session IDInstrumentDurationDescriptionSession DateVideo
- No sessions yet. Start your first practice session! -
{displayNumber}{session.instrument}{session.duration}{session.description}{session.session_date} - {session.youtube_url ? ( - - View - - ) : ( - -- - )} -
+ + + + + + + + - ); - }) + + + {paginatedSessions.length === 0 ? ( + + + + ) : ( + paginatedSessions.map((session, index) => ( + + + + + + + + + )) + )} + +
#InstrumentDurationDescriptionSession DateVideo
+ {sessions.length === 0 + ? 'No sessions yet. Start your first practice session!' + : 'No sessions match your search.'} +
{startIndex + index + 1}{session.instrument}{session.duration}{session.description}{session.session_date} + {session.youtube_url ? ( + + View + + ) : ( + -- + )} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Showing {startIndex + 1}-{Math.min(startIndex + sessionsPerPage, filteredSessions.length)} of {filteredSessions.length} sessions +

+
+ + {[...Array(totalPages)].map((_, i) => ( + + ))} + +
+
)} - - -
+ + ); + })()} No error
; +} + +describe('ErrorBoundary', () => { + const originalError = console.error; + beforeAll(() => { + console.error = jest.fn(); + }); + afterAll(() => { + console.error = originalError; + }); + beforeEach(() => { + shouldThrow = false; + }); + + it('renders children when no error', () => { + render( + +
Child content
+
+ ); + expect(screen.getByText('Child content')).toBeInTheDocument(); + }); + + it('renders fallback UI on error', () => { + shouldThrow = true; + render( + + + + ); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); + }); + + it('renders custom fallback on error', () => { + shouldThrow = true; + render( + Custom error
}> + + + ); + expect(screen.getByText('Custom error')).toBeInTheDocument(); + }); + + it('recovers after clicking try again', async () => { + shouldThrow = true; + const user = userEvent.setup({ delay: null }); + render( + + + + ); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + + // Stop throwing before clicking try again + shouldThrow = false; + await user.click(screen.getByRole('button', { name: /try again/i })); + + expect(screen.getByText('No error')).toBeInTheDocument(); + }); +}); diff --git a/frontend/next-app/src/components/ui/error-boundary.tsx b/frontend/next-app/src/components/ui/error-boundary.tsx new file mode 100644 index 0000000..b927c92 --- /dev/null +++ b/frontend/next-app/src/components/ui/error-boundary.tsx @@ -0,0 +1,62 @@ +"use client"; + +import React, { Component, ErrorInfo, ReactNode } from "react"; +import { Button } from "./button"; +import { Card, CardContent, CardHeader, CardTitle } from "./card"; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("ErrorBoundary caught:", error, errorInfo); + } + + handleReset = () => { + this.setState({ hasError: false, error: null }); + }; + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+ + + Something went wrong + + +

+ An unexpected error occurred. Please try again. +

+ +
+
+
+ ); + } + + return this.props.children; + } +} diff --git a/frontend/next-app/src/components/ui/skeleton.tsx b/frontend/next-app/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..2cdf440 --- /dev/null +++ b/frontend/next-app/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils"; + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +export { Skeleton }; diff --git a/session/views.py b/session/views.py index 6a0caa9..7820571 100644 --- a/session/views.py +++ b/session/views.py @@ -54,8 +54,15 @@ def post(self, request): if instrument not in valid_instruments: return Response({'error': f'Invalid instrument. Must be one of: {", ".join(valid_instruments)}'}, status=status.HTTP_400_BAD_REQUEST) + api_key = os.environ.get('OPENAI_API_KEY') + if not api_key: + return Response( + {'error': 'AI recommendations are not configured. Please contact the administrator.'}, + status=status.HTTP_503_SERVICE_UNAVAILABLE + ) + try: - client = OpenAI(api_key=os.environ.get('OPENAI_API_KEY')) + client = OpenAI(api_key=api_key) response = client.chat.completions.create( model='gpt-4o-mini', messages=[ From aa3987782f9f4893e63320c987eee3b5411ed9e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 10:46:22 +0000 Subject: [PATCH 06/14] Fix backend tests for OpenAI API key check Add @patch.dict(os.environ) to recommendation tests so the new API key validation doesn't short-circuit mocked OpenAI calls. Add test for missing API key returning 503. https://claude.ai/code/session_01KeKHEPaYoNsosovTEZKQQC --- session/tests.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/session/tests.py b/session/tests.py index ccd2d0a..c3ceafb 100644 --- a/session/tests.py +++ b/session/tests.py @@ -7,6 +7,7 @@ from rest_framework.authtoken.models import Token from datetime import timedelta, date, datetime from unittest.mock import patch, MagicMock +import os from .models import Session, Tag @@ -619,6 +620,7 @@ def test_invalid_instrument_returns_400(self): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn('error', response.data) + @patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}) @patch('session.views.OpenAI') def test_successful_recommendation(self, mock_openai_cls): """Test successful recommendation with mocked OpenAI""" @@ -643,6 +645,7 @@ def test_unauthenticated_returns_401_or_403(self): response = self.client.post(self.url, self.valid_data, format='json') self.assertIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]) + @patch.dict(os.environ, {'OPENAI_API_KEY': 'test-key'}) @patch('session.views.OpenAI') def test_openai_error_returns_500(self, mock_openai_cls): """Test that OpenAI API errors return 500""" @@ -654,3 +657,12 @@ def test_openai_error_returns_500(self, mock_openai_cls): self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) self.assertIn('error', response.data) + + @patch.dict(os.environ, {}, clear=True) + def test_missing_api_key_returns_503(self): + """Test that missing OpenAI API key returns 503""" + response = self.client.post(self.url, self.valid_data, format='json') + + self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE) + self.assertIn('error', response.data) + self.assertIn('not configured', response.data['error']) From e69b306c4c3ca869fd41164e71bc197243526260 Mon Sep 17 00:00:00 2001 From: Daniel Adekugbe <78769670+Dandiggas@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:24:05 +0000 Subject: [PATCH 07/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- accounts/urls.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/accounts/urls.py b/accounts/urls.py index 8dd3530..df35804 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -3,9 +3,9 @@ urlpatterns = [ - path('api/v1/current-user/', current_user_view, name='current-user'), - path('api/v1/update-profile/', update_profile_view, name='update-profile'), - path('api/v1/logout/', logout_view, name='logout'), + path('api/v1/current-user/', current_user_view, name='current-user'), + path('api/v1/update-profile/', update_profile_view, name='update-profile'), + path('api/v1/logout/', logout_view, name='logout'), ] From cc6ff1a23a95b29b33601fcf5ba38109d03f8fd8 Mon Sep 17 00:00:00 2001 From: Daniel Adekugbe <78769670+Dandiggas@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:24:39 +0000 Subject: [PATCH 08/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- frontend/next-app/src/components/ui/skeleton.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/next-app/src/components/ui/skeleton.tsx b/frontend/next-app/src/components/ui/skeleton.tsx index 2cdf440..da702d8 100644 --- a/frontend/next-app/src/components/ui/skeleton.tsx +++ b/frontend/next-app/src/components/ui/skeleton.tsx @@ -1,3 +1,4 @@ +import * as React from "react"; import { cn } from "@/lib/utils"; function Skeleton({ From 4aeef64e1a639a72c057ceef443d44c5ae8af194 Mon Sep 17 00:00:00 2001 From: Daniel Adekugbe <78769670+Dandiggas@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:25:15 +0000 Subject: [PATCH 09/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- frontend/next-app/src/app/practice-timer/page.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/next-app/src/app/practice-timer/page.tsx b/frontend/next-app/src/app/practice-timer/page.tsx index 7f419e1..00d886c 100644 --- a/frontend/next-app/src/app/practice-timer/page.tsx +++ b/frontend/next-app/src/app/practice-timer/page.tsx @@ -159,6 +159,9 @@ export default function PracticeTimerPage() { gain2.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 1.1); osc2.start(audioContext.currentTime + 0.3); osc2.stop(audioContext.currentTime + 1.1); + osc2.onended = () => { + audioContext.close(); + }; } catch { // Audio not supported, silent fallback } From 35024489fc9483cf565702d65cabcc18c97e9ed8 Mon Sep 17 00:00:00 2001 From: Daniel Adekugbe <78769670+Dandiggas@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:25:36 +0000 Subject: [PATCH 10/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- frontend/next-app/src/app/practice-timer/page.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/frontend/next-app/src/app/practice-timer/page.tsx b/frontend/next-app/src/app/practice-timer/page.tsx index 00d886c..9e835fe 100644 --- a/frontend/next-app/src/app/practice-timer/page.tsx +++ b/frontend/next-app/src/app/practice-timer/page.tsx @@ -34,13 +34,6 @@ export default function PracticeTimerPage() { const videoId = extractVideoId(youtubeUrl); const hasVideo = Boolean(videoId); - useEffect(() => { - // Request notification permission - if ('Notification' in window && Notification.permission === 'default') { - Notification.requestPermission(); - } - }, []); - useEffect(() => { // Check if there's an active timer const checkActiveTimer = async () => { From a057ad9ff4c1bca9f984109cba06137059779cb7 Mon Sep 17 00:00:00 2001 From: Daniel Adekugbe <78769670+Dandiggas@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:25:42 +0000 Subject: [PATCH 11/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- frontend/next-app/src/components/profile/ProfilePage.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/next-app/src/components/profile/ProfilePage.tsx b/frontend/next-app/src/components/profile/ProfilePage.tsx index d5f3c3c..7929032 100644 --- a/frontend/next-app/src/components/profile/ProfilePage.tsx +++ b/frontend/next-app/src/components/profile/ProfilePage.tsx @@ -67,6 +67,9 @@ const ProfilePage = () => { headers: { 'Authorization': `Token ${token}` } }); setSessions(sessionResponse.data); + // Keep practiceSessions in sync with sessions so mutations from the form + // are reflected in the main sessions list and charts. + setPracticeSessions(sessionResponse.data as PracticeSession[]); } catch (error) { console.error('Error fetching data', error); setError('Error fetching data'); @@ -77,6 +80,12 @@ const ProfilePage = () => { fetchData(); }, []); + // Whenever practiceSessions changes (e.g., via PracticeSessionForm), + // update sessions so all charts/tables using sessions see the latest data. + useEffect(() => { + setSessions(practiceSessions as unknown as Session[]); + }, [practiceSessions]); + if (isLoading) { return (
From 28129c92144598cedb90456bca04c45738948334 Mon Sep 17 00:00:00 2001 From: Daniel Adekugbe <78769670+Dandiggas@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:25:48 +0000 Subject: [PATCH 12/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/components/practice/PracticeSessionForm.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/next-app/src/components/practice/PracticeSessionForm.tsx b/frontend/next-app/src/components/practice/PracticeSessionForm.tsx index 4cdc3e4..5bcc173 100644 --- a/frontend/next-app/src/components/practice/PracticeSessionForm.tsx +++ b/frontend/next-app/src/components/practice/PracticeSessionForm.tsx @@ -137,6 +137,12 @@ const PracticeSessionForm: React.FC = ({ practiceSessi }); setSelectedTags([]); setSelectedSessionIdForUpdate(''); + + // Ensure any parent components that maintain their own sessions state + // are refreshed after a successful create/update. + if (typeof window !== 'undefined') { + window.location.reload(); + } } catch (err) { if (axios.isAxiosError(err)) { setError('Error: ' + err.response?.data?.message || err.message); From 2cabc8fc9fd09eaa1dbad2aa137c67c862f1ef44 Mon Sep 17 00:00:00 2001 From: Daniel Adekugbe <78769670+Dandiggas@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:25:52 +0000 Subject: [PATCH 13/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../next-app/src/components/practice/PracticeSessionForm.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/next-app/src/components/practice/PracticeSessionForm.tsx b/frontend/next-app/src/components/practice/PracticeSessionForm.tsx index 5bcc173..48e5236 100644 --- a/frontend/next-app/src/components/practice/PracticeSessionForm.tsx +++ b/frontend/next-app/src/components/practice/PracticeSessionForm.tsx @@ -172,6 +172,8 @@ const PracticeSessionForm: React.FC = ({ practiceSessi setAllSessions(prev => prev.filter(s => s.session_id !== selectedSessionIdForDeletion)); setPracticeSessions(prev => prev.filter(s => s.session_id !== selectedSessionIdForDeletion)); setSelectedSessionIdForDeletion(''); + // Ensure all parts of the UI that depend on the canonical sessions list are refreshed + window.location.reload(); } catch (error) { setError('Failed to delete the session'); } finally { From b5a89137ebe46c7e637223e5dd3fc076a45ec02f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Mar 2026 11:15:03 +0000 Subject: [PATCH 14/14] Add rate limiting, security logging, CSP, and hardened headers - Add DRF throttling: 20/min anonymous, 60/min authenticated, 5/min auth endpoints - Add custom AuthRateThrottle on login/registration to prevent brute force - Add security event logging for django.security and django.request - Set explicit SameSite=Lax on session and CSRF cookies - Add Next.js security headers: CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, X-XSS-Protection https://claude.ai/code/session_01KeKHEPaYoNsosovTEZKQQC --- accounts/auth_urls.py | 23 +++++++++++++ accounts/throttles.py | 5 +++ django_project/settings.py | 55 ++++++++++++++++++++++++++++---- django_project/urls.py | 5 +-- frontend/next-app/next.config.ts | 44 ++++++++++++++++++++++++- 5 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 accounts/auth_urls.py create mode 100644 accounts/throttles.py diff --git a/accounts/auth_urls.py b/accounts/auth_urls.py new file mode 100644 index 0000000..1a9fb12 --- /dev/null +++ b/accounts/auth_urls.py @@ -0,0 +1,23 @@ +from django.urls import path, include +from dj_rest_auth.views import LoginView +from dj_rest_auth.registration.views import RegisterView +from .throttles import AuthRateThrottle + + +class ThrottledLoginView(LoginView): + throttle_classes = [AuthRateThrottle] + + +class ThrottledRegisterView(RegisterView): + throttle_classes = [AuthRateThrottle] + + +urlpatterns = [ + path("login/", ThrottledLoginView.as_view(), name="rest_login"), + path("", include("dj_rest_auth.urls")), +] + +registration_urlpatterns = [ + path("", ThrottledRegisterView.as_view(), name="rest_register"), + path("", include("dj_rest_auth.registration.urls")), +] diff --git a/accounts/throttles.py b/accounts/throttles.py new file mode 100644 index 0000000..5256be7 --- /dev/null +++ b/accounts/throttles.py @@ -0,0 +1,5 @@ +from rest_framework.throttling import AnonRateThrottle + + +class AuthRateThrottle(AnonRateThrottle): + scope = "auth" diff --git a/django_project/settings.py b/django_project/settings.py index cf78e81..87b7b3d 100644 --- a/django_project/settings.py +++ b/django_project/settings.py @@ -179,15 +179,24 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" AUTH_USER_MODEL = "accounts.CustomUser" -REST_FRAMEWORK = { +REST_FRAMEWORK = { "DEFAULT_PERMISSION_CLASSES": [ - "rest_framework.permissions.IsAuthenticated",], - "DEFAULT_AUTHENTICATION_CLASSES":[ - "rest_framework.authentication.SessionAuthentication", - "rest_framework.authentication.TokenAuthentication", - + "rest_framework.permissions.IsAuthenticated", + ], + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + "rest_framework.authentication.TokenAuthentication", ], "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_THROTTLE_CLASSES": [ + "rest_framework.throttling.AnonRateThrottle", + "rest_framework.throttling.UserRateThrottle", + ], + "DEFAULT_THROTTLE_RATES": { + "anon": "20/minute", + "user": "60/minute", + "auth": "5/minute", + }, } # CORS Configuration - Allow frontend to make requests to backend @@ -219,6 +228,40 @@ SECURE_HSTS_PRELOAD = True +# Cookie security +SESSION_COOKIE_SAMESITE = "Lax" +CSRF_COOKIE_SAMESITE = "Lax" + +# Security logging +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "{levelname} {asctime} {module} {message}", + "style": "{", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + }, + "loggers": { + "django.security": { + "handlers": ["console"], + "level": "WARNING", + "propagate": True, + }, + "django.request": { + "handlers": ["console"], + "level": "WARNING", + "propagate": True, + }, + }, +} + SPECTACULAR_SETTINGS = { "TITLE": "Musicians Practice App", "DESCRIPTION": "The backend for my musician practice app", diff --git a/django_project/urls.py b/django_project/urls.py index 1a13b34..c280686 100644 --- a/django_project/urls.py +++ b/django_project/urls.py @@ -17,14 +17,15 @@ from django.contrib import admin from django.urls import path, include from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView +from accounts.auth_urls import urlpatterns as auth_urlpatterns, registration_urlpatterns urlpatterns = [ path("admin/", admin.site.urls), path("api/v1/", include("session.urls")), path("api-auth/", include("rest_framework.urls")), - path("api/v1/dj-rest-auth/", include("dj_rest_auth.urls")), - path("api/v1/dj-rest-auth/registration/", include("dj_rest_auth.registration.urls")), + path("api/v1/dj-rest-auth/", include(auth_urlpatterns)), + path("api/v1/dj-rest-auth/registration/", include(registration_urlpatterns)), path("api/schema/", SpectacularAPIView.as_view(), name="schema"), path("api/schema/swagger-ui/", SpectacularSwaggerView.as_view(url_name = "schema"), name="swagger-ui"), path('', include('accounts.urls')), diff --git a/frontend/next-app/next.config.ts b/frontend/next-app/next.config.ts index e9ffa30..43516da 100644 --- a/frontend/next-app/next.config.ts +++ b/frontend/next-app/next.config.ts @@ -1,7 +1,49 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "X-Frame-Options", + value: "DENY", + }, + { + key: "X-XSS-Protection", + value: "1; mode=block", + }, + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=()", + }, + { + key: "Content-Security-Policy", + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' data:", + `connect-src 'self' ${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}`, + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join("; "), + }, + ], + }, + ]; + }, }; export default nextConfig;