diff --git a/.env.example b/.env.example index 179f50b2..39b49257 100644 --- a/.env.example +++ b/.env.example @@ -1,209 +1,304 @@ -# ============================================================================ -# AREA Project - Environment Variables Template -# ============================================================================ -# Copy this file to .env and fill in your actual values -# NEVER commit .env with real credentials to git! -# ============================================================================ - -# ============================================================================ -# 1. DATABASE CONFIGURATION (PostgreSQL) -# ============================================================================ +# ╔════════════════════════════════════════════════════════════════════════════╗ +# ║ AREA PROJECT - ENVIRONMENT VARIABLES TEMPLATE ║ +# ╚════════════════════════════════════════════════════════════════════════════╝ +# +# 📋 Purpose: Template file for creating your local .env +# 🔒 Security: Contains NO real credentials (safe to commit to git) +# +# 📝 How to use: +# 1. Copy this file: cp .env.example .env +# 2. Fill in your credentials in .env +# 3. Validate: ./scripts/validate-env.sh +# 4. Start: docker-compose up +# +# 🔗 Full documentation: README.md +# +# ══════════════════════════════════════════════════════════════════════════════ + +# ────────────────────────────────────────────────────────────────────────────── +# 📊 1. DATABASE CONFIGURATION (PostgreSQL 16) +# ────────────────────────────────────────────────────────────────────────────── +# PostgreSQL stores all application data (users, services, areas, executions) +# Access from host: localhost:5432 (DBeaver, pgAdmin, psql) +# Access from containers: db:5432 (internal Docker network) + DB_USER=area_user -DB_PASSWORD=change-this-secure-password +DB_PASSWORD=your-secure-database-password-here DB_NAME=area_db -DB_HOST=db # Docker service name (internal container communication) -DB_PORT=5433 # EXTERNAL port (host → container mapping 5433:5432) - # Use this port from your host machine (DBeaver, psql, etc.) - # Containers use internal port 5432 via DB_HOST=db - -# ============================================================================ -# 2. REDIS CONFIGURATION -# ============================================================================ -REDIS_HOST=redis # Docker service name (internal container communication) -REDIS_PORT=6379 # EXTERNAL port (host → container mapping) - # Change if port 6379 is already in use on your host -REDIS_URL=redis://${REDIS_HOST}:6379/0 # Internal URL used by containers (fixed port 6379) - -# ============================================================================ -# 3. DJANGO BACKEND CONFIGURATION -# ============================================================================ -BACKEND_PORT=8080 # EXTERNAL port (host → container mapping) - # Access API from host at http://localhost:8080 +DB_HOST=db # Docker service name (don't change) +DB_PORT=5432 + +# ────────────────────────────────────────────────────────────────────────────── +# 🔴 2. REDIS CONFIGURATION +# ────────────────────────────────────────────────────────────────────────────── +# Redis is used for: +# • Message broker (Celery task queue) +# • Caching (Django cache backend) +# • Session storage +# Access from host: localhost:6379 (redis-cli, RedisInsight) + +REDIS_HOST=redis # Docker service name (don't change) +REDIS_PORT=6379 +REDIS_URL=redis://redis:6379/0 + +# ────────────────────────────────────────────────────────────────────────────── +# 🐍 3. DJANGO BACKEND CONFIGURATION +# ────────────────────────────────────────────────────────────────────────────── +# Django REST API server +# Access at: http://localhost:8080 + +BACKEND_PORT=8080 DJANGO_SETTINGS_MODULE=area_project.settings -# SECRET_KEY: Generate a secure random key for production -# Use: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" -SECRET_KEY=your-very-secret-django-key-change-in-production +# Django Secret Key (cryptographic signing, sessions, passwords) +# 🔐 Generate: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" +SECRET_KEY=your-django-secret-key-change-me-in-production -# DEBUG: Set to False in production! +# Debug mode (NEVER True in production) DEBUG=True -# ENVIRONMENT: Defines which Django settings module to load -# - local : Local development (venv, SQLite, no Docker) -# - docker : Docker development (PostgreSQL, Redis, hot reload, DEBUG=True) -# - production : Docker production (PostgreSQL, Redis, security hardened, DEBUG=False) -ENVIRONMENT=docker - -# ============================================================================ -# 4. DJANGO SUPERUSER (Optional - auto-created on first startup if set) -# ============================================================================ -DJANGO_SUPERUSER_EMAIL=admin@areaction.app -DJANGO_SUPERUSER_PASSWORD=change-this-secure-password - -# ============================================================================ -# 5. LOGGING CONFIGURATION -# ============================================================================ +# Environment: development | staging | production +ENVIRONMENT=development + +# ────────────────────────────────────────────────────────────────────────────── +# 👤 4. DJANGO SUPERUSER (Auto-created on first startup) +# ────────────────────────────────────────────────────────────────────────────── +# Admin panel access: http://localhost:8080/admin/ + +DJANGO_SUPERUSER_EMAIL=admin@example.com +DJANGO_SUPERUSER_PASSWORD=your-admin-password + +# ────────────────────────────────────────────────────────────────────────────── +# 📝 5. LOGGING CONFIGURATION +# ────────────────────────────────────────────────────────────────────────────── +# Logs location: backend/logs/django.log +# Levels: DEBUG < INFO < WARNING < ERROR < CRITICAL + LOG_LEVEL=INFO DJANGO_LOG_FILE=/app/logs/django.log -# ============================================================================ -# 6. JWT AUTHENTICATION -# ============================================================================ -# JWT_SIGNING_KEY: Used to sign JWT tokens. Generate a secure random key! -JWT_SIGNING_KEY=your-jwt-signing-key-change-in-production - -# ============================================================================ -# 7. EMAIL CONFIGURATION (SendGrid SMTP) -# ============================================================================ -# SendGrid allows email sending without port blocking issues (works on DigitalOcean) -# Get API key from: https://app.sendgrid.com/settings/api_keys -# Setup: Create a verified sender at https://app.sendgrid.com/settings/sender_auth +# ────────────────────────────────────────────────────────────────────────────── +# 🔑 6. JWT AUTHENTICATION +# ────────────────────────────────────────────────────────────────────────────── +# JWT tokens for API authentication +# 🔐 Generate: openssl rand -base64 64 + +JWT_SIGNING_KEY=your-jwt-signing-key-change-me-in-production + +# ────────────────────────────────────────────────────────────────────────────── +# 📧 7. EMAIL CONFIGURATION (SendGrid SMTP) +# ────────────────────────────────────────────────────────────────────────────── +# For sending transactional emails (verification, notifications) +# +# 📚 Setup instructions: +# 1. Create account: https://signup.sendgrid.com/ +# 2. Verify sender: https://app.sendgrid.com/settings/sender_auth +# 3. Create API key: https://app.sendgrid.com/settings/api_keys +# • Permissions: Full Access to "Mail Send" +# 4. Paste API key below +# +# Why port 2525? Cloud providers often block 25, 465, 587 + EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend EMAIL_HOST=smtp.sendgrid.net -EMAIL_PORT=587 +EMAIL_PORT=2525 EMAIL_USE_TLS=True EMAIL_USE_SSL=False EMAIL_HOST_USER=apikey EMAIL_HOST_PASSWORD=your-sendgrid-api-key-here DEFAULT_FROM_EMAIL=noreply@yourdomain.com -# ============================================================================ -# 8. FRONTEND CONFIGURATION -# ============================================================================ -FRONTEND_DEV_PORT=5173 # EXTERNAL port (Vite dev server in development) - # Access frontend at http://localhost:5173 -FRONTEND_PORT=8081 # EXTERNAL port (Nginx in production) -FRONTEND_URL=http://localhost:${FRONTEND_PORT} # Public-facing frontend URL - -# Vite environment variables (must be prefixed with VITE_ to be exposed to client) -# In development: http://localhost:${BACKEND_PORT} (external port) -# In production: https://areaction.app/api -VITE_API_BASE=http://localhost:${BACKEND_PORT} - -# ============================================================================ -# 9. CORS AND SECURITY SETTINGS -# ============================================================================ -# ALLOWED_HOSTS: Comma-separated list of allowed host/domain names +# ────────────────────────────────────────────────────────────────────────────── +# ⚛️ 8. FRONTEND CONFIGURATION +# ────────────────────────────────────────────────────────────────────────────── +# React + Vite web application +# Development: http://localhost:5173 (hot reload) +# Production: http://localhost:8081 (nginx) + +FRONTEND_PORT=5173 +FRONTEND_URL=http://localhost:5173 + +# API endpoint (exposed to browser via Vite) +# Must be prefixed with VITE_ to be accessible in frontend code +VITE_API_BASE=http://localhost:8080 + +# ────────────────────────────────────────────────────────────────────────────── +# 🔒 9. CORS & SECURITY SETTINGS +# ────────────────────────────────────────────────────────────────────────────── + +# Allowed hosts (comma-separated, no spaces) +# Add your production domain here ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0,10.0.2.2 -# CORS_ALLOW_ALL_ORIGINS: Set to False in production and configure specific origins +# Allow all origins (only for development) CORS_ALLOW_ALL_ORIGINS=True -# Security flags (set to True in production with HTTPS) +# HTTPS security (set True in production) SECURE_SSL_REDIRECT=False SESSION_COOKIE_SECURE=False CSRF_COOKIE_SECURE=False -# ============================================================================ -# 10. OAUTH2 CONFIGURATION (External Service Authentication) -# ============================================================================ +# ────────────────────────────────────────────────────────────────────────────── +# 🔐 10. OAUTH2 CONFIGURATION +# ────────────────────────────────────────────────────────────────────────────── +# External service integrations (users connect their accounts) + +# ┌──────────────────────────────────────────────────────────────────────────┐ +# │ 🔵 GOOGLE (Gmail, Calendar, YouTube) │ +# └──────────────────────────────────────────────────────────────────────────┘ +# 📚 Setup: https://console.cloud.google.com/apis/credentials +# 1. Create project +# 2. Enable APIs: Gmail, Calendar, YouTube +# 3. Create OAuth 2.0 credentials (Web application) +# 4. Add authorized redirect URI: +# http://localhost:8080/auth/oauth/google/callback/ -# --- Google OAuth2 --- -# Get credentials from: https://console.cloud.google.com/apis/credentials GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=your-google-client-secret -GOOGLE_REDIRECT_URI=http://localhost:${BACKEND_PORT}/auth/google/callback/ +GOOGLE_REDIRECT_URI=http://localhost:8080/auth/oauth/google/callback/ + +# ┌──────────────────────────────────────────────────────────────────────────┐ +# │ 🐙 GITHUB (Repositories, Issues, Pull Requests) │ +# └──────────────────────────────────────────────────────────────────────────┘ +# 📚 Setup: https://github.com/settings/developers +# 1. New OAuth App +# 2. Authorization callback URL: +# http://localhost:8080/auth/oauth/github/callback/ -# --- GitHub OAuth2 --- -# Get credentials from: https://github.com/settings/developers GITHUB_CLIENT_ID=your-github-client-id GITHUB_CLIENT_SECRET=your-github-client-secret -GITHUB_REDIRECT_URI=http://localhost:${BACKEND_PORT}/auth/oauth/github/callback/ +GITHUB_REDIRECT_URI=http://localhost:8080/auth/oauth/github/callback/ -# --- Twitch OAuth2 --- -# Get credentials from: https://dev.twitch.tv/console/apps -TWITCH_CLIENT_ID=your-twitch-client-id -TWITCH_CLIENT_SECRET=your-twitch-client-secret -TWITCH_REDIRECT_URI=http://localhost:${BACKEND_PORT}/auth/oauth/twitch/callback/ +# ┌──────────────────────────────────────────────────────────────────────────┐ +# │ 🤖 GITHUB APP (For Webhooks) │ +# └──────────────────────────────────────────────────────────────────────────┘ +# 📚 Setup: https://github.com/settings/apps/new +# 1. Create GitHub App +# 2. Set webhook URL: https://your-domain.com/webhooks/github/ +# 3. Download private key (.pem file) + +GITHUB_APP_ID=your-github-app-id +GITHUB_APP_CLIENT_ID=your-github-app-client-id +GITHUB_APP_CLIENT_SECRET=your-github-app-client-secret +GITHUB_APP_NAME=your-github-app-name +GITHUB_APP_PRIVATE_KEY_PATH=../your-app-private-key.pem + +# Frontend variable +VITE_GITHUB_APP_NAME=your-github-app-name + +# ┌──────────────────────────────────────────────────────────────────────────┐ +# │ 📝 NOTION (Pages, Databases) │ +# └──────────────────────────────────────────────────────────────────────────┘ +# 📚 Setup: https://www.notion.so/my-integrations +# 1. Create new integration +# 2. Add redirect URI: +# http://localhost:8080/auth/oauth/notion/callback/ + +NOTION_CLIENT_ID=your-notion-client-id +NOTION_CLIENT_SECRET=your-notion-client-secret +NOTION_REDIRECT_URI=http://localhost:8080/auth/oauth/notion/callback/ + +# ┌──────────────────────────────────────────────────────────────────────────┐ +# │ 💬 SLACK (Messages, Channels) │ +# └──────────────────────────────────────────────────────────────────────────┘ +# 📚 Setup: https://api.slack.com/apps +# 1. Create new app +# 2. OAuth & Permissions → Redirect URLs: +# http://localhost:8080/auth/oauth/slack/callback/ -# --- Slack OAuth2 --- -# Get credentials from: https://api.slack.com/apps SLACK_CLIENT_ID=your-slack-client-id SLACK_CLIENT_SECRET=your-slack-client-secret SLACK_REDIRECT_URI=http://localhost:8080/auth/oauth/slack/callback/ -# --- Spotify OAuth2 --- -# Get credentials from: https://developer.spotify.com/dashboard/ +# ┌──────────────────────────────────────────────────────────────────────────┐ +# │ 🎵 SPOTIFY (Playlists, Playback) │ +# └──────────────────────────────────────────────────────────────────────────┘ +# 📚 Setup: https://developer.spotify.com/dashboard/ +# 1. Create app +# 2. Edit Settings → Redirect URIs: +# http://localhost:8080/auth/oauth/spotify/callback/ + SPOTIFY_CLIENT_ID=your-spotify-client-id SPOTIFY_CLIENT_SECRET=your-spotify-client-secret SPOTIFY_REDIRECT_URI=http://localhost:8080/auth/oauth/spotify/callback/ -# --- Notion OAuth2 --- -# Get credentials from: https://www.notion.so/my-integrations -NOTION_CLIENT_ID=your-notion-client-id -NOTION_CLIENT_SECRET=your-notion-client-secret -NOTION_REDIRECT_URI=http://localhost:8080/auth/oauth/notion/callback/ +# ┌──────────────────────────────────────────────────────────────────────────┐ +# │ 🎮 TWITCH (Streams, Channels) │ +# └──────────────────────────────────────────────────────────────────────────┘ +# 📚 Setup: https://dev.twitch.tv/console/apps +# 1. Register application +# 2. OAuth Redirect URLs: +# http://localhost:8080/auth/oauth/twitch/callback/ + +TWITCH_CLIENT_ID=your-twitch-client-id +TWITCH_CLIENT_SECRET=your-twitch-client-secret +TWITCH_REDIRECT_URI=http://localhost:8080/auth/oauth/twitch/callback/ -# ============================================================================ -# 11. WEBHOOK SECRETS (for validating incoming webhooks) -# ============================================================================ -# JSON dictionary format for webhook secrets -# Generate secure random strings: openssl rand -base64 32 -# Format: WEBHOOK_SECRETS='{"service_name":"secret_key",...}' +# ────────────────────────────────────────────────────────────────────────────── +# 🪝 11. WEBHOOK SECRETS +# ────────────────────────────────────────────────────────────────────────────── +# Secrets for validating incoming webhooks from external services +# Format: JSON dictionary on a single line # -# To enable webhooks for a service: -# 1. Generate a secure secret: openssl rand -base64 32 -# 2. Add it to this JSON dictionary -# 3. Configure the webhook URL in the service's settings: -# - GitHub: https://github.com/settings/hooks -# - Slack: https://api.slack.com/apps → Event Subscriptions -# - Notion: https://www.notion.so/my-integrations → Webhooks -# Webhook URL format: https://your-domain.com/webhooks// -# (Example: https://areaction.app/webhooks/notion/) -WEBHOOK_SECRETS='{ - "github":"dev_secret_github_123", - "slack":"dev_secret_slack_123", - "gmail":"dev_secret_gmail_123", - "notion":"your-notion-webhook-secret-here" -}' - -# ============================================================================ -# 12. OPENWEATHERMAP CONFIGURATION -# ============================================================================ -OPENWEATHERMAP_API_KEY=openweathermap-api-key - -# ============================================================================ -# 13. CELERY CONFIGURATION (Task Queue) -# ============================================================================ +# 🔐 Generate secrets: openssl rand -base64 32 +# +# Each service that sends webhooks needs: +# 1. A secret key (generated above) +# 2. Webhook URL configured in service settings: +# https://your-domain.com/webhooks// + +WEBHOOK_SECRETS='{"github":"your-github-webhook-secret","slack":"your-slack-webhook-secret","gmail":"your-gmail-webhook-secret","notion":"your-notion-webhook-secret"}' + +# ────────────────────────────────────────────────────────────────────────────── +# 🌤️ 12. OPENWEATHERMAP API +# ────────────────────────────────────────────────────────────────────────────── +# For weather-based triggers and actions +# 📚 Get free API key: https://openweathermap.org/api + +OPENWEATHERMAP_API_KEY=your-openweathermap-api-key + +# ────────────────────────────────────────────────────────────────────────────── +# ⚙️ 13. CELERY CONFIGURATION +# ────────────────────────────────────────────────────────────────────────────── +# Celery handles asynchronous tasks (webhooks, polling, scheduled tasks) + CELERY_TIMEZONE=UTC -# CELERY_TASK_ALWAYS_EAGER: If True, tasks execute synchronously (useful for testing) + +# Run tasks synchronously (for testing only, disables Celery) CELERY_TASK_ALWAYS_EAGER=False -# ============================================================================ -# 14. MONITORING (Flower - Celery Task Monitor) -# ============================================================================ -FLOWER_PORT=5566 # EXTERNAL port (host → container mapping 5566:5555) - # Access Flower UI at http://localhost:5566 +# ────────────────────────────────────────────────────────────────────────────── +# 🌺 14. FLOWER MONITORING +# ────────────────────────────────────────────────────────────────────────────── +# Web UI for monitoring Celery tasks +# Access at: http://localhost:5566 + +FLOWER_PORT=5566 + +# ────────────────────────────────────────────────────────────────────────────── +# 🐳 15. DOCKER COMPOSE +# ────────────────────────────────────────────────────────────────────────────── -# ============================================================================ -# 15. DOCKER CONFIGURATION -# ============================================================================ COMPOSE_PROJECT_NAME=area -# ============================================================================ -# PRODUCTION DEPLOYMENT NOTES -# ============================================================================ -# When deploying to production: -# 1. Set DEBUG=False -# 2. Set ENVIRONMENT=production -# 3. Generate new SECRET_KEY and JWT_SIGNING_KEY -# 4. Set SECURE_SSL_REDIRECT=True -# 5. Set SESSION_COOKIE_SECURE=True -# 6. Set CSRF_COOKIE_SECURE=True -# 7. Set CORS_ALLOW_ALL_ORIGINS=False -# 8. Update ALLOWED_HOSTS with your production domain -# 9. Update all OAuth redirect URIs with production URLs -# 10. Update VITE_API_BASE with production API URL -# 11. Generate strong webhook secrets -# 12. Use real OAuth credentials (not dev placeholders) -# ============================================================================ +# ══════════════════════════════════════════════════════════════════════════════ +# 🚀 PRODUCTION CHECKLIST +# ══════════════════════════════════════════════════════════════════════════════ +# Before deploying to production, ensure: +# +# ☐ Set DEBUG=False +# ☐ Set ENVIRONMENT=production +# ☐ Generate new SECRET_KEY +# ☐ Generate new JWT_SIGNING_KEY +# ☐ Set SECURE_SSL_REDIRECT=True +# ☐ Set SESSION_COOKIE_SECURE=True +# ☐ Set CSRF_COOKIE_SECURE=True +# ☐ Set CORS_ALLOW_ALL_ORIGINS=False +# ☐ Update ALLOWED_HOSTS with production domain +# ☐ Update all OAuth redirect URIs with HTTPS URLs +# ☐ Update VITE_API_BASE with production API URL +# ☐ Generate strong webhook secrets +# ☐ Use production OAuth credentials +# ☐ Never commit .env to git! +# +# ══════════════════════════════════════════════════════════════════════════════ diff --git a/.env.production b/.env.production index 34e98b00..765bb920 100644 --- a/.env.production +++ b/.env.production @@ -1,173 +1,343 @@ -# ============================================================================ -# AREA Project - Production Environment Template -# ============================================================================ -# This file contains production-ready defaults -# Copy to .env and customize for your production deployment -# ============================================================================ - -# ============================================================================ -# 1. DATABASE CONFIGURATION (PostgreSQL) -# ============================================================================ +# ╔════════════════════════════════════════════════════════════════════════════╗ +# ║ AREA PROJECT - PRODUCTION ENVIRONMENT TEMPLATE (.env.production) ║ +# ╚════════════════════════════════════════════════════════════════════════════╝ +# +# 📋 Purpose: Template for production deployment +# 🔒 Security: Contains NO real credentials (safe to commit) +# +# 📝 How to use: +# 1. Copy to production server: scp .env.production server:/opt/area/.env +# 2. Fill in production credentials +# 3. Secure permissions: chmod 600 /opt/area/.env +# 4. Deploy: cd /opt/area && ./deployment/manage.sh deploy +# +# 🌐 Architecture: +# • Domain: your-domain.com +# • SSL: Let's Encrypt via nginx +# • Reverse Proxy: nginx (HTTPS → HTTP:8000) +# • Backend: Gunicorn + Django (port 8000) +# • Frontend: React SPA (served by nginx) +# • Database: PostgreSQL (isolated volume) +# • Cache: Redis (isolated volume) +# • Tasks: Celery (worker + beat + flower) +# +# ══════════════════════════════════════════════════════════════════════════════ + +# ────────────────────────────────────────────────────────────────────────────── +# 📊 1. DATABASE CONFIGURATION (PostgreSQL) +# ────────────────────────────────────────────────────────────────────────────── +# Production database with strong credentials +# 🔐 Generate password: openssl rand -base64 32 + DB_USER=area_user -DB_PASSWORD=CHANGE_ME_STRONG_PASSWORD +DB_PASSWORD=your-strong-database-password-here DB_NAME=area_db DB_HOST=db DB_PORT=5432 -# ============================================================================ -# 2. REDIS CONFIGURATION -# ============================================================================ +# ────────────────────────────────────────────────────────────────────────────── +# 🔴 2. REDIS CONFIGURATION +# ────────────────────────────────────────────────────────────────────────────── + +REDIS_HOST=redis REDIS_PORT=6379 REDIS_URL=redis://redis:6379/0 -# ============================================================================ -# 3. DJANGO BACKEND CONFIGURATION -# ============================================================================ -BACKEND_PORT=8080 -DJANGO_SETTINGS_MODULE=area_project.settings +# ────────────────────────────────────────────────────────────────────────────── +# 🐍 3. DJANGO BACKEND CONFIGURATION +# ────────────────────────────────────────────────────────────────────────────── +# Production API server configuration -# Generate with: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" -SECRET_KEY=CHANGE_ME_GENERATE_SECURE_KEY +BACKEND_PORT=8000 # Internal port (nginx proxies here) +DJANGO_SETTINGS_MODULE=area_project.settings -# CRITICAL: Must be False in production! -DEBUG=False +# Django Secret Key (NEVER reuse from development) +# 🔐 Generate: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" +SECRET_KEY=your-production-secret-key-64-chars-minimum +# Production mode settings +DEBUG=False # CRITICAL: Never True in production ENVIRONMENT=production -# ============================================================================ -# 4. DJANGO SUPERUSER -# ============================================================================ -DJANGO_SUPERUSER_EMAIL=admin@yourdomain.com -DJANGO_SUPERUSER_PASSWORD=CHANGE_ME_STRONG_PASSWORD - -# ============================================================================ -# 5. LOGGING CONFIGURATION -# ============================================================================ -LOG_LEVEL=WARNING -DJANGO_LOG_FILE=/app/logs/django.log - -# ============================================================================ -# 6. JWT AUTHENTICATION -# ============================================================================ -# Generate with: openssl rand -hex 32 -JWT_SIGNING_KEY=CHANGE_ME_GENERATE_SECURE_KEY - -# ============================================================================ -# 7. EMAIL CONFIGURATION (SendGrid SMTP) -# ============================================================================ -# SendGrid is recommended for production (works on DigitalOcean where ports 25/465/587 are blocked) -# Get API key from: https://app.sendgrid.com/settings/api_keys -# Setup verified sender: https://app.sendgrid.com/settings/sender_auth +# ────────────────────────────────────────────────────────────────────────────── +# 👤 4. DJANGO SUPERUSER +# ────────────────────────────────────────────────────────────────────────────── +# Production admin account +# Access: https://your-domain.com/admin/ + +DJANGO_SUPERUSER_EMAIL=admin@your-domain.com +DJANGO_SUPERUSER_PASSWORD=your-strong-admin-password + +# ────────────────────────────────────────────────────────────────────────────── +# 📝 5. LOGGING CONFIGURATION +# ────────────────────────────────────────────────────────────────────────────── + +LOG_LEVEL=INFO +DJANGO_LOG_FILE=/opt/area/logs/django.log + +# ────────────────────────────────────────────────────────────────────────────── +# 🔑 6. JWT AUTHENTICATION +# ────────────────────────────────────────────────────────────────────────────── +# JWT signing key for API tokens +# 🔐 Generate: openssl rand -base64 64 + +JWT_SIGNING_KEY=your-production-jwt-key-64-chars-minimum + +# ────────────────────────────────────────────────────────────────────────────── +# 📧 7. EMAIL CONFIGURATION (SendGrid) +# ────────────────────────────────────────────────────────────────────────────── +# Production email service +# 📚 Setup: https://app.sendgrid.com/ +# +# Why SendGrid? +# • Reliable delivery (99.99% uptime SLA) +# • Works on all cloud providers (DigitalOcean, AWS, etc.) +# • Port 2525 bypasses common SMTP blocks +# • Free tier: 100 emails/day + EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend EMAIL_HOST=smtp.sendgrid.net -EMAIL_PORT=587 +EMAIL_PORT=2525 EMAIL_USE_TLS=True EMAIL_USE_SSL=False EMAIL_HOST_USER=apikey -EMAIL_HOST_PASSWORD=CHANGE_ME_SENDGRID_API_KEY -DEFAULT_FROM_EMAIL=noreply@yourdomain.com +EMAIL_HOST_PASSWORD=your-sendgrid-api-key +DEFAULT_FROM_EMAIL=noreply@your-domain.com + +# ────────────────────────────────────────────────────────────────────────────── +# ⚛️ 8. FRONTEND CONFIGURATION +# ────────────────────────────────────────────────────────────────────────────── +# Production frontend configuration + +FRONTEND_PORT=8081 # Internal nginx port +FRONTEND_URL=https://your-domain.com -# ============================================================================ -# 8. FRONTEND CONFIGURATION -# ============================================================================ -FRONTEND_PORT=443 -FRONTEND_URL=https://yourdomain.com +# API endpoint (used at build time) +# Nginx will proxy /api/*, /auth/*, /webhooks/*, /admin/* to backend +VITE_API_BASE=https://your-domain.com -# CRITICAL: Must point to your production API -VITE_API_BASE=https://yourdomain.com/api +# ────────────────────────────────────────────────────────────────────────────── +# 🔒 9. CORS & SECURITY SETTINGS +# ────────────────────────────────────────────────────────────────────────────── +# Production security hardening -# ============================================================================ -# 9. CORS AND SECURITY SETTINGS -# ============================================================================ -# Add your production domain(s) -ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com +# Allowed hosts (comma-separated, no spaces) +ALLOWED_HOSTS=your-domain.com,www.your-domain.com,localhost,127.0.0.1 -# CRITICAL: Must be False in production - configure CORS_ALLOWED_ORIGINS in settings.py +# CORS - Restrict to production domain only CORS_ALLOW_ALL_ORIGINS=False +CORS_ALLOWED_ORIGINS=https://your-domain.com,https://www.your-domain.com -# CRITICAL: Must be True in production with HTTPS -SECURE_SSL_REDIRECT=True -SESSION_COOKIE_SECURE=True -CSRF_COOKIE_SECURE=True +# HTTPS Security Headers +# Note: SECURE_SSL_REDIRECT=False because nginx handles SSL +SECURE_SSL_REDIRECT=False # nginx terminates SSL +SESSION_COOKIE_SECURE=True # Cookies only over HTTPS +CSRF_COOKIE_SECURE=True # CSRF only over HTTPS +SECURE_HSTS_SECONDS=31536000 # 1 year +SECURE_HSTS_INCLUDE_SUBDOMAINS=True +SECURE_HSTS_PRELOAD=True -# ============================================================================ -# 10. OAUTH2 CONFIGURATION -# ============================================================================ +# ────────────────────────────────────────────────────────────────────────────── +# 🔐 10. OAUTH2 CONFIGURATION (PRODUCTION CREDENTIALS) +# ────────────────────────────────────────────────────────────────────────────── +# ⚠️ Use PRODUCTION OAuth credentials, not development ones! +# ⚠️ Update ALL redirect URIs to use HTTPS production URLs + +# ┌──────────────────────────────────────────────────────────────────────────┐ +# │ 🔵 GOOGLE (Gmail, Calendar, YouTube) │ +# └──────────────────────────────────────────────────────────────────────────┘ +# 📚 Console: https://console.cloud.google.com/apis/credentials +# Redirect URI: https://your-domain.com/auth/oauth/google/callback/ -# Google OAuth2 GOOGLE_CLIENT_ID=your-production-google-client-id.apps.googleusercontent.com -GOOGLE_CLIENT_SECRET=CHANGE_ME_GOOGLE_SECRET -GOOGLE_REDIRECT_URI=https://yourdomain.com/auth/oauth/google/callback/ +GOOGLE_CLIENT_SECRET=your-production-google-client-secret +GOOGLE_REDIRECT_URI=https://your-domain.com/auth/oauth/google/callback/ + +# ┌──────────────────────────────────────────────────────────────────────────┐ +# │ 🐙 GITHUB (Repositories, Issues, PRs) │ +# └──────────────────────────────────────────────────────────────────────────┘ +# 📚 Console: https://github.com/settings/developers +# Callback URL: https://your-domain.com/auth/oauth/github/callback/ -# GitHub OAuth2 GITHUB_CLIENT_ID=your-production-github-client-id -GITHUB_CLIENT_SECRET=CHANGE_ME_GITHUB_SECRET -GITHUB_REDIRECT_URI=https://yourdomain.com/auth/oauth/github/callback/ +GITHUB_CLIENT_SECRET=your-production-github-client-secret +GITHUB_REDIRECT_URI=https://your-domain.com/auth/oauth/github/callback/ -# Twitch OAuth2 -TWITCH_CLIENT_ID=your-production-twitch-client-id -TWITCH_CLIENT_SECRET=CHANGE_ME_TWITCH_SECRET -TWITCH_REDIRECT_URI=https://yourdomain.com/auth/oauth/twitch/callback/ +# ┌──────────────────────────────────────────────────────────────────────────┐ +# │ 🤖 GITHUB APP (Webhooks) │ +# └──────────────────────────────────────────────────────────────────────────┘ +# 📚 Console: https://github.com/settings/apps +# Webhook URL: https://your-domain.com/webhooks/github/ + +GITHUB_APP_ID=your-github-app-id +GITHUB_APP_CLIENT_ID=your-github-app-client-id +GITHUB_APP_CLIENT_SECRET=your-github-app-client-secret +GITHUB_APP_NAME=your-github-app-name +GITHUB_APP_PRIVATE_KEY_PATH=/opt/area/github-app-private-key.pem + +VITE_GITHUB_APP_NAME=your-github-app-name + +# ┌──────────────────────────────────────────────────────────────────────────┐ +# │ 📝 NOTION │ +# └──────────────────────────────────────────────────────────────────────────┘ +# 📚 Console: https://www.notion.so/my-integrations +# Redirect: https://your-domain.com/auth/oauth/notion/callback/ + +NOTION_CLIENT_ID=your-production-notion-client-id +NOTION_CLIENT_SECRET=your-production-notion-client-secret +NOTION_REDIRECT_URI=https://your-domain.com/auth/oauth/notion/callback/ + +# ┌──────────────────────────────────────────────────────────────────────────┐ +# │ 💬 SLACK │ +# └──────────────────────────────────────────────────────────────────────────┘ +# 📚 Console: https://api.slack.com/apps +# Redirect: https://your-domain.com/auth/oauth/slack/callback/ -# Slack OAuth2 SLACK_CLIENT_ID=your-production-slack-client-id -SLACK_CLIENT_SECRET=CHANGE_ME_SLACK_SECRET -SLACK_REDIRECT_URI=https://yourdomain.com/auth/oauth/slack/callback/ +SLACK_CLIENT_SECRET=your-production-slack-client-secret +SLACK_REDIRECT_URI=https://your-domain.com/auth/oauth/slack/callback/ + +# ┌──────────────────────────────────────────────────────────────────────────┐ +# │ 🎵 SPOTIFY │ +# └──────────────────────────────────────────────────────────────────────────┘ +# 📚 Console: https://developer.spotify.com/dashboard/ +# Redirect: https://your-domain.com/auth/oauth/spotify/callback/ -# Spotify OAuth2 SPOTIFY_CLIENT_ID=your-production-spotify-client-id -SPOTIFY_CLIENT_SECRET=CHANGE_ME_SPOTIFY_SECRET -SPOTIFY_REDIRECT_URI=https://yourdomain.com/auth/oauth/spotify/callback/ - -# ============================================================================ -# 11. WEBHOOK SECRETS -# ============================================================================ -# JSON dictionary format for webhook secrets -# Generate secure random strings: openssl rand -base64 32 -# Format: WEBHOOK_SECRETS='{"service_name":"secret_key",...}' -WEBHOOK_SECRETS='{ - "github":"CHANGE_ME_GENERATE_SECURE_SECRET", - "slack":"CHANGE_ME_GENERATE_SECURE_SECRET", - "gmail":"CHANGE_ME_GENERATE_SECURE_SECRET" -}' - -# ============================================================================ -# 12. OPENWEATHERMAP CONFIGURATION -# ============================================================================ -OPENWEATHERMAP_API_KEY=openweathermap-api-key - -# ============================================================================ -# 13. CELERY CONFIGURATION -# ============================================================================ +SPOTIFY_CLIENT_SECRET=your-production-spotify-client-secret +SPOTIFY_REDIRECT_URI=https://your-domain.com/auth/oauth/spotify/callback/ + +# ┌──────────────────────────────────────────────────────────────────────────┐ +# │ 🎮 TWITCH │ +# └──────────────────────────────────────────────────────────────────────────┘ +# 📚 Console: https://dev.twitch.tv/console/apps +# Redirect: https://your-domain.com/auth/oauth/twitch/callback/ + +TWITCH_CLIENT_ID=your-production-twitch-client-id +TWITCH_CLIENT_SECRET=your-production-twitch-client-secret +TWITCH_REDIRECT_URI=https://your-domain.com/auth/oauth/twitch/callback/ + +# ────────────────────────────────────────────────────────────────────────────── +# 🪝 11. WEBHOOK SECRETS (Production) +# ────────────────────────────────────────────────────────────────────────────── +# Cryptographically secure secrets for webhook validation +# 🔐 Generate: openssl rand -base64 32 +# +# Configure in external services: +# • GitHub: https://github.com///settings/hooks +# • Notion: https://www.notion.so/my-integrations → Webhooks +# • Slack: https://api.slack.com/apps/ → Event Subscriptions +# +# Format: Single-line JSON (no line breaks) + +WEBHOOK_SECRETS='{"github":"your-secure-github-secret","notion":"your-secure-notion-secret","slack":"your-secure-slack-secret"}' + +# ────────────────────────────────────────────────────────────────────────────── +# 🌤️ 12. GOOGLE PUSH NOTIFICATIONS (Webhooks) +# ────────────────────────────────────────────────────────────────────────────── +# Real-time push notifications (more efficient than polling) + +GMAIL_WEBHOOK_ENABLED=true +CALENDAR_WEBHOOK_ENABLED=true +YOUTUBE_WEBHOOK_ENABLED=true + +GMAIL_WEBHOOK_URL=https://your-domain.com/webhooks/gmail/ +CALENDAR_WEBHOOK_URL=https://your-domain.com/webhooks/calendar/ +YOUTUBE_WEBHOOK_URL=https://your-domain.com/webhooks/youtube/ + +# Watch renewal interval (6 days = renew 1 day before 7-day expiry) +GOOGLE_WATCH_RENEWAL_INTERVAL=518400 + +# ────────────────────────────────────────────────────────────────────────────── +# 🌍 13. OPENWEATHERMAP API +# ────────────────────────────────────────────────────────────────────────────── +# 📚 Get API key: https://openweathermap.org/api + +OPENWEATHERMAP_API_KEY=your-production-openweathermap-key + +# ────────────────────────────────────────────────────────────────────────────── +# ⚙️ 14. CELERY CONFIGURATION +# ────────────────────────────────────────────────────────────────────────────── + CELERY_TIMEZONE=UTC CELERY_TASK_ALWAYS_EAGER=False -# ============================================================================ -# 14. MONITORING (Flower) -# ============================================================================ +# ────────────────────────────────────────────────────────────────────────────── +# 🌺 15. FLOWER MONITORING +# ────────────────────────────────────────────────────────────────────────────── +# Web UI for Celery task monitoring +# Access: http://your-server-ip:5566 (firewall protected) + FLOWER_PORT=5566 +FLOWER_USER=admin +FLOWER_PASSWORD=your-strong-flower-password + +# ────────────────────────────────────────────────────────────────────────────── +# 🏭 16. GUNICORN (WSGI Server) +# ────────────────────────────────────────────────────────────────────────────── +# Workers formula: (2 × CPU cores) + 1 +# For 2 vCPU server: 4-5 workers recommended + +GUNICORN_WORKERS=4 + +# ────────────────────────────────────────────────────────────────────────────── +# 🐳 17. DOCKER COMPOSE +# ────────────────────────────────────────────────────────────────────────────── + +COMPOSE_PROJECT_NAME=area + +# ────────────────────────────────────────────────────────────────────────────── +# 🌐 18. URL CONFIGURATION +# ────────────────────────────────────────────────────────────────────────────── + +BACKEND_URL=https://your-domain.com +FRONTEND_URL=https://your-domain.com -# ============================================================================ -# 15. DOCKER CONFIGURATION -# ============================================================================ -COMPOSE_PROJECT_NAME=area_production - -# ============================================================================ -# PRODUCTION DEPLOYMENT CHECKLIST -# ============================================================================ -# ✅ All CHANGE_ME values replaced with secure credentials -# ✅ SECRET_KEY and JWT_SIGNING_KEY generated with secure random strings -# ✅ DEBUG=False -# ✅ ENVIRONMENT=production -# ✅ SECURE_SSL_REDIRECT=True -# ✅ SESSION_COOKIE_SECURE=True -# ✅ CSRF_COOKIE_SECURE=True -# ✅ CORS_ALLOW_ALL_ORIGINS=False -# ✅ ALLOWED_HOSTS updated with production domain(s) -# ✅ OAuth redirect URIs updated with production URLs -# ✅ VITE_API_BASE points to production API -# ✅ Webhook secrets generated with strong random values -# ✅ SSL/TLS certificates configured -# ✅ Database backups configured -# ✅ Monitoring and logging configured -# ============================================================================ +# ══════════════════════════════════════════════════════════════════════════════ +# 🚀 DEPLOYMENT CHECKLIST +# ══════════════════════════════════════════════════════════════════════════════ +# +# Before going live: +# +# ☐ Replace ALL placeholder values with real production credentials +# ☐ Verify DEBUG=False +# ☐ Verify ENVIRONMENT=production +# ☐ Generate unique SECRET_KEY and JWT_SIGNING_KEY +# ☐ Use strong database password (openssl rand -base64 32) +# ☐ Configure production OAuth credentials in all services +# ☐ Update ALL OAuth redirect URIs to HTTPS production URLs +# ☐ Generate webhook secrets (openssl rand -base64 32) +# ☐ Configure webhooks in external services +# ☐ Set up SendGrid verified sender +# ☐ Configure Let's Encrypt SSL certificates +# ☐ Test email delivery +# ☐ Test OAuth flows for all services +# ☐ Test webhook delivery +# ☐ Set file permissions: chmod 600 .env +# ☐ Never commit .env to git +# ☐ Set up encrypted backups +# ☐ Configure monitoring and alerts +# ☐ Document recovery procedures +# +# ══════════════════════════════════════════════════════════════════════════════ +# +# 📊 PRODUCTION COMMANDS +# ══════════════════════════════════════════════════════════════════════════════ +# +# Deploy: +# cd /opt/area +# docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build +# +# View logs: +# docker-compose -f docker-compose.yml -f docker-compose.prod.yml logs -f +# +# Stop: +# docker-compose -f docker-compose.yml -f docker-compose.prod.yml down +# +# Backup database: +# docker-compose exec db pg_dump -U area_user area_db > backup-$(date +%Y%m%d).sql +# +# Renew SSL: +# certbot renew --nginx +# +# ══════════════════════════════════════════════════════════════════════════════ diff --git a/.gitignore b/.gitignore index 25eccf9b..3d3abf6a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ *.ref my_refs(do_not_delete) backend/staticfiles/ +*htmlcov* node_modules/ diff --git a/HOWTOCONTRIBUTE.md b/HOWTOCONTRIBUTE.md index ff82bbd4..ebda227f 100644 --- a/HOWTOCONTRIBUTE.md +++ b/HOWTOCONTRIBUTE.md @@ -121,6 +121,109 @@ sequenceDiagram ### 1.4 Core Models +#### Class Diagram + +```mermaid +classDiagram + class User { + +Integer id + +String username + +String email + +Boolean email_verified + +DateTime created_at + +DateTime updated_at + +list~Area~ areas + +list~ServiceToken~ tokens + } + + class Service { + +Integer id + +String name + +String description + +Status status + +list~Action~ actions + +list~Reaction~ reactions + } + + class Action { + +Integer id + +String name + +String description + +JSONField config_schema + +ForeignKey service + } + + class Reaction { + +Integer id + +String name + +String description + +JSONField config_schema + +ForeignKey service + } + + class Area { + +Integer id + +String name + +JSONField action_config + +JSONField reaction_config + +Status status + +DateTime created_at + +ForeignKey owner + +ForeignKey action + +ForeignKey reaction + +list~Execution~ executions + } + + class Execution { + +Integer id + +String external_event_id + +Status status + +JSONField trigger_data + +JSONField result_data + +String error_message + +DateTime created_at + +DateTime started_at + +DateTime completed_at + +Integer retry_count + +ForeignKey area + } + + class ServiceToken { + +Integer id + +String access_token + +String refresh_token + +DateTime expires_at + +Boolean is_expired + +DateTime last_used_at + +DateTime created_at + +ForeignKey user + +ForeignKey service + } + + class ActionState { + +Integer id + +DateTime last_checked_at + +String last_event_id + +JSONField metadata + +OneToOneField area + } + + User "1" --> "*" Area : owns + User "1" --> "*" ServiceToken : has + Service "1" --> "*" Action : provides + Service "1" --> "*" Reaction : provides + Service "1" --> "*" ServiceToken : authenticates + Area "*" --> "1" Action : triggers_on + Area "*" --> "1" Reaction : executes + Area "1" --> "*" Execution : creates + Area "1" --> "1" ActionState : tracks_state + Execution "*" --> "1" Area : belongs_to + ServiceToken "*" --> "1" User : belongs_to + ServiceToken "*" --> "1" Service : for_service +``` + +#### Model Relationships Summary + ```python # Simplified model relationships Service (name, description, status) @@ -128,7 +231,8 @@ Service (name, description, status) └── Reactions (1-to-many) Area (user, name, action, reaction, configs, status) - └── Executions (1-to-many, with idempotency key) + ├── Executions (1-to-many, with idempotency key) + └── ActionState (1-to-1, for polling state) User ├── Areas (1-to-many) @@ -438,6 +542,242 @@ curl http://localhost:8080/api/services/ | jq '.results[] | select(.name=="githu - [ ] Service accessible via API - [ ] Documentation updated +### 3.3 Service Implementation Examples + +Understanding service complexity helps you choose the right implementation approach: + +#### Example 1: Simple Service (No OAuth) - Weather + +**Complexity**: ⭐ Low + +```python +{ + "name": "weather", + "description": "Weather data and alerts integration", + "status": Service.Status.ACTIVE, + "actions": [ + { + "name": "weather_rain_detected", + "description": "Triggered when rain is detected", + "config_schema": { + "location": { + "type": "string", + "label": "Location", + "required": True, + "placeholder": "Paris, France" + } + } + } + ], + "reactions": [] +} +``` + +**Key Points:** +- ❌ No OAuth required (uses API key in settings) +- ✅ Actions only (monitoring) +- ✅ Polling-based (Celery Beat task every 15 minutes) +- ✅ Simple external API (OpenWeatherMap) + +#### Example 2: Medium Service (OAuth + Webhooks) - Notion + +**Complexity**: ⭐⭐ Medium + +```python +{ + "name": "notion", + "description": "Note-taking and database management", + "status": Service.Status.ACTIVE, + "actions": [ + { + "name": "notion_page_created", + "description": "Triggered when new page is created", + "config_schema": {} + }, + { + "name": "notion_database_item_added", + "description": "Triggered when item added to database", + "config_schema": { + "database_id": { + "type": "string", + "label": "Database ID", + "required": True + } + } + } + ], + "reactions": [ + { + "name": "notion_create_page", + "description": "Create a new page in Notion", + "config_schema": { + "parent_page_id": {"type": "string", "required": True}, + "title": {"type": "string", "required": True}, + "content": {"type": "text", "required": False} + } + } + ] +} +``` + +**Key Points:** +- ✅ OAuth2 required (user authorization) +- ✅ Webhook support (real-time notifications) +- ✅ Both actions and reactions +- ⚠️ Requires webhook configuration in Notion dashboard +- 📚 Implementation: `backend/users/oauth/notion.py` + +#### Example 3: Complex Service (OAuth + EventSub) - Twitch + +**Complexity**: ⭐⭐⭐ High + +```python +{ + "name": "twitch", + "description": "Live streaming platform integration", + "status": Service.Status.ACTIVE, + "actions": [ + { + "name": "twitch_stream_online", + "description": "Triggered when stream goes live", + "config_schema": { + "broadcaster_username": { + "type": "string", + "label": "Streamer Username", + "required": True + } + } + }, + { + "name": "twitch_new_follower", + "description": "Triggered when channel gets new follower" + } + ], + "reactions": [ + { + "name": "twitch_send_chat_message", + "description": "Send message to chat", + "config_schema": { + "broadcaster_username": {"type": "string", "required": True}, + "message": {"type": "string", "required": True} + } + }, + { + "name": "twitch_create_clip", + "description": "Create a clip of current stream" + } + ] +} +``` + +**Key Points:** +- ✅ OAuth2 with specific scopes (chat:read, clips:edit, etc.) +- ✅ EventSub webhooks (Twitch's webhook system) +- ⚠️ Requires external subscription management +- ⚠️ Webhook URL must be HTTPS and publicly accessible +- 🔧 Complex: needs `WebhookSubscription` model tracking +- 📚 Implementation: `backend/users/oauth/twitch.py` + `backend/automations/webhooks.py` + +**EventSub Subscription Flow:** + +```python +# When user connects Twitch OAuth +1. User authorizes → Get access token +2. Backend creates EventSub subscriptions: + - POST https://api.twitch.tv/helix/eventsub/subscriptions + - For each event type (stream.online, channel.follow, etc.) +3. Twitch sends verification challenge +4. Backend responds with challenge +5. Subscription becomes active +6. Events sent to: https://areaction.app/webhooks/twitch/ +``` + +#### Example 4: API-Only Service (OAuth, No Webhooks) - Spotify + +**Complexity**: ⭐⭐ Medium + +```python +{ + "name": "spotify", + "description": "Music streaming and playback control", + "status": Service.Status.ACTIVE, + "actions": [], # No actions - reactions only + "reactions": [ + { + "name": "spotify_play_track", + "description": "Play a specific track", + "config_schema": { + "track_uri": { + "type": "string", + "label": "Track URI", + "required": True, + "placeholder": "spotify:track:6rqhFgbbKwnb9MLmUQDhG6" + } + } + }, + { + "name": "spotify_create_playlist", + "description": "Create a new playlist", + "config_schema": { + "name": {"type": "string", "required": True}, + "description": {"type": "string", "required": False}, + "public": {"type": "boolean", "default": False} + } + } + ] +} +``` + +**Key Points:** +- ✅ OAuth2 with refresh tokens +- ❌ No webhooks (Spotify doesn't provide real-time events) +- ✅ Reactions only (control playback, manage library) +- ✅ Token refresh handled automatically by `OAuthManager` +- 📚 Implementation: `backend/users/oauth/spotify.py` + +**Why no actions?** Spotify's API doesn't provide webhook notifications for events like "song played" or "playlist updated". For user activity monitoring, you'd need to poll the API repeatedly, which is rate-limited and inefficient. + +#### Example 5: Google Multi-Service (OAuth + PubSub) - Gmail/Calendar/YouTube + +**Complexity**: ⭐⭐⭐ High + +```python +# Single OAuth, multiple services +GOOGLE_OAUTH → gmail, google_calendar, youtube + +# Gmail: Push notifications via Google Cloud Pub/Sub +{ + "name": "gmail", + "actions": [ + {"name": "gmail_new_email", "description": "Any new email"}, + {"name": "gmail_new_from_sender", "config": {"sender": "..."}} + ], + "reactions": [ + {"name": "gmail_send_email", "config": {"to": "...", "subject": "..."}} + ] +} + +# YouTube: PubSubHubbub (WebSub protocol) +{ + "name": "youtube", + "actions": [ + {"name": "youtube_new_video", "description": "New upload detected"} + ], + "reactions": [ + {"name": "youtube_post_comment", "config": {"video_id": "...", "text": "..."}} + ] +} +``` + +**Key Points:** +- ✅ Single OAuth grants access to all Google services +- ✅ Gmail: Cloud Pub/Sub webhooks (watch API) +- ✅ YouTube: PubSubHubbub (WebSub standard) +- ✅ Calendar: Calendar API push notifications +- ⚠️ Complex setup (GCP project, webhook URLs) +- 🔧 Watch renewal required (Gmail watches expire after 7 days) +- 📚 Implementation: `backend/automations/google_webhook_views.py` + --- ## 4. Adding an Action diff --git a/README.md b/README.md index 730b5728..0e904fa8 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,149 @@ The script checks: --- +## 🎯 Available Services + +The platform supports **12 integrated services** with **61 total features** (31 actions + 30 reactions): + +| Service | Actions | Reactions | OAuth Required | Description | +|---------|---------|-----------|----------------|-------------| +| **Timer** | 2 | 0 | ❌ | Time-based scheduling (daily, weekly) | +| **GitHub** | 2 | 1 | ✅ | Repository automation (issues, PRs) | +| **Gmail** | 4 | 3 | ✅ | Email automation (read, send, label) | +| **Google Calendar** | 2 | 2 | ✅ | Event management (create, update) | +| **Email** | 0 | 1 | ❌ | Generic SMTP email sending | +| **Slack** | 4 | 3 | ✅ | Team messaging (messages, alerts) | +| **Weather** | 6 | 0 | ❌ | Weather monitoring (rain, temperature) | +| **Twitch** | 5 | 6 | ✅ | Streaming platform (chat, clips, titles) | +| **Debug** | 1 | 1 | ❌ | Testing and debugging tools | +| **Spotify** | 0 | 7 | ✅ | Music playback control | +| **Notion** | 3 | 3 | ✅ | Note-taking (pages, databases) | +| **YouTube** | 2 | 3 | ✅ | Video platform (comments, playlists) | + +**Total: 12 services • 31 actions • 30 reactions** + +--- + +## 📚 API Documentation + +Once services are running, access interactive API documentation: + +- **Swagger UI**: +- **ReDoc**: +- **OpenAPI Schema**: +- **Service Discovery**: +- **Admin Panel**: +- **Celery Monitoring**: + +### Key API Endpoints + +#### Authentication + +```bash +# Register new account +POST /auth/register/ +{ + "username": "john", + "email": "john@example.com", + "password": "securepass123" +} + +# Login and get JWT token +POST /auth/login/ +{ + "username": "john", + "password": "securepass123" +} + +# OAuth initiate (redirects to provider) +GET /auth/oauth/google/ +GET /auth/oauth/github/ + +# OAuth callback (handled automatically) +GET /auth/oauth/{provider}/callback/?code=xxx&state=yyy +``` + +#### Services & Automations + +```bash +# List all available services +GET /api/services/ + +# List all actions +GET /api/actions/ + +# List all reactions +GET /api/reactions/ + +# Create new automation (Area) +POST /api/areas/ +{ + "name": "GitHub to Slack", + "action": 1, # github_new_issue + "reaction": 5, # slack_send_message + "action_config": {"repository": "owner/repo"}, + "reaction_config": {"channel": "#dev", "message": "New issue: {title}"} +} + +# List user's automations +GET /api/areas/ + +# View execution history +GET /api/executions/ +``` + +#### Webhooks + +```bash +# Generic webhook receiver +POST /webhooks/{service}/ + +# Service-specific webhooks +POST /webhooks/gmail/ # Gmail push notifications +POST /webhooks/github/ # GitHub App webhooks +POST /webhooks/calendar/ # Google Calendar notifications +POST /webhooks/youtube/ # YouTube PubSubHubbub +``` + +### Example: Complete Automation Flow + +```bash +# 1. Register and login +curl -X POST http://localhost:8080/auth/register/ \ + -H "Content-Type: application/json" \ + -d '{"username":"demo","email":"demo@example.com","password":"demo123"}' + +# 2. Get JWT token +TOKEN=$(curl -X POST http://localhost:8080/auth/login/ \ + -H "Content-Type: application/json" \ + -d '{"username":"demo","password":"demo123"}' | jq -r '.access') + +# 3. Connect GitHub (opens browser) +open "http://localhost:8080/auth/oauth/github/?next=/profile" + +# 4. Create automation +curl -X POST http://localhost:8080/api/areas/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "New Issue Alert", + "action": 1, + "reaction": 15, + "action_config": {"repository": "My-Epitech-Organisation/Area"}, + "reaction_config": { + "recipient": "admin@example.com", + "subject": "New GitHub Issue", + "body": "Issue #{issue_number}: {title}" + } + }' + +# 5. View executions +curl http://localhost:8080/api/executions/ \ + -H "Authorization: Bearer $TOKEN" +``` + +--- + ## Frontend Quick start (frontend) diff --git a/backend/automations/management/commands/init_services.py b/backend/automations/management/commands/init_services.py index 4e2b6f5f..6f2a54cc 100755 --- a/backend/automations/management/commands/init_services.py +++ b/backend/automations/management/commands/init_services.py @@ -444,6 +444,29 @@ def _create_services(self): { "name": "send_email", "description": "Send an email to specified recipients", + "config_schema": { + "recipient": { + "type": "string", + "label": "Recipient Email", + "description": "Email address of the recipient", + "required": True, + "placeholder": "user@example.com", + }, + "subject": { + "type": "string", + "label": "Email Subject", + "description": "Subject line of the email", + "required": True, + "placeholder": "AREA Notification", + }, + "body": { + "type": "text", + "label": "Email Body", + "description": "Content of the email message", + "required": True, + "placeholder": "This is an automated notification from your AREA automation.", + }, + }, }, ], }, @@ -612,25 +635,6 @@ def _create_services(self): }, ], }, - { - "name": "webhook", - "description": "HTTP webhook integration for custom integrations", - "status": Service.Status.ACTIVE, - "actions": [ - { - "name": "webhook_trigger", - "description": ( - "Triggered when a webhook receives an HTTP request" - ), - }, - ], - "reactions": [ - { - "name": "webhook_post", - "description": "Send an HTTP POST request to a specified URL", - }, - ], - }, { "name": "weather", "description": "Weather data and alerts integration", diff --git a/backend/automations/tasks.py b/backend/automations/tasks.py index 63eafd6b..e446f733 100755 --- a/backend/automations/tasks.py +++ b/backend/automations/tasks.py @@ -2658,16 +2658,42 @@ def _execute_reaction_logic( return {"logged": True, "message": message} elif reaction_name == "send_email": - # Placeholder for email sending + # Real implementation: Send email via Django's email backend (SendGrid) + from django.conf import settings + from django.core.mail import send_mail + recipient = reaction_config.get("recipient") subject = reaction_config.get("subject", "AREA Notification") - logger.info(f"[REACTION EMAIL] Would send to {recipient}: {subject}") - return { - "sent": True, - "recipient": recipient, - "subject": subject, - "note": "Email sending not yet implemented", - } + body = reaction_config.get( + "body", "This is an automated notification from your AREA automation." + ) + + if not recipient: + raise ValueError("Recipient email is required for send_email") + + try: + # Send email using Django's configured email backend (SendGrid SMTP) + send_mail( + subject=subject, + message=body, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[recipient], + fail_silently=False, + ) + + logger.info(f"[REACTION EMAIL] ✅ Sent email to {recipient}: {subject}") + return { + "sent": True, + "recipient": recipient, + "subject": subject, + "from": settings.DEFAULT_FROM_EMAIL, + } + + except Exception as e: + logger.error( + f"[REACTION EMAIL] ❌ Failed to send email to {recipient}: {e}" + ) + raise ValueError(f"Email sending failed: {str(e)}") from e elif reaction_name == "slack_message": # Placeholder for Slack message diff --git a/backend/automations/tests/test_email_reactions.py b/backend/automations/tests/test_email_reactions.py new file mode 100644 index 00000000..20d6691f --- /dev/null +++ b/backend/automations/tests/test_email_reactions.py @@ -0,0 +1,190 @@ +""" +Tests for email reaction execution. +Tests the send_email reaction through _execute_reaction_logic. +""" + +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.core import mail +from django.test import TestCase + +from automations.models import Action, Area, Reaction, Service +from automations.tasks import _execute_reaction_logic + +User = get_user_model() + + +class EmailReactionTests(TestCase): + """Test send_email reaction execution.""" + + def setUp(self): + """Set up test fixtures.""" + self.user = User.objects.create_user( + username="testuser", email="test@example.com", password="testpass123" + ) + + # Create email service + self.email_service = Service.objects.create( + name="email", description="Email Service" + ) + + # Create Action and Reaction (required by Area model) + self.action = Action.objects.create( + service=self.email_service, + name="test_action", + description="Test action", + ) + + self.reaction = Reaction.objects.create( + service=self.email_service, + name="test_reaction", + description="Test reaction", + ) + + # Create a test automation area + self.area = Area.objects.create( + name="Test Email Area", + owner=self.user, + action=self.action, + reaction=self.reaction, + status=Area.Status.ACTIVE, + ) + + def test_send_email_success(self): + """Test successful email sending.""" + result = _execute_reaction_logic( + reaction_name="send_email", + reaction_config={ + "recipient": "test@example.com", + "subject": "Test Subject", + "body": "Test Body", + }, + trigger_data={}, + area=self.area, + ) + + # Check result + self.assertTrue(result["sent"]) + self.assertEqual(result["recipient"], "test@example.com") + self.assertEqual(result["subject"], "Test Subject") + + # Check email was sent + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].subject, "Test Subject") + self.assertEqual(mail.outbox[0].body, "Test Body") + self.assertEqual(mail.outbox[0].to, ["test@example.com"]) + + def test_send_email_missing_recipient(self): + """Test send_email with missing recipient.""" + with self.assertRaisesMessage(ValueError, "Recipient email is required"): + _execute_reaction_logic( + reaction_name="send_email", + reaction_config={ + "subject": "Test Subject", + "body": "Test Body", + }, + trigger_data={}, + area=self.area, + ) + + def test_send_email_invalid_recipient(self): + """Test send_email with invalid recipient email.""" + # Django's send_mail doesn't validate email format, + # so we need to test that it attempts to send + result = _execute_reaction_logic( + reaction_name="send_email", + reaction_config={ + "recipient": "invalid-email", + "subject": "Test", + "body": "Test", + }, + trigger_data={}, + area=self.area, + ) + + # Should still succeed (Django doesn't validate format) + self.assertTrue(result["sent"]) + + @patch("django.core.mail.send_mail", side_effect=Exception("SMTP error")) + def test_send_email_smtp_error(self, mock_send_mail): + """Test send_email when SMTP fails.""" + with self.assertRaisesMessage(ValueError, "Email sending failed"): + _execute_reaction_logic( + reaction_name="send_email", + reaction_config={ + "recipient": "test@example.com", + "subject": "Test", + "body": "Test", + }, + trigger_data={}, + area=self.area, + ) + + def test_send_email_multiple_recipients(self): + """Test sending email - single recipient only.""" + result = _execute_reaction_logic( + reaction_name="send_email", + reaction_config={ + "recipient": "test@example.com", + "subject": "Test", + "body": "Test", + }, + trigger_data={}, + area=self.area, + ) + + self.assertTrue(result["sent"]) + self.assertEqual(len(mail.outbox), 1) + + def test_send_email_with_html(self): + """Test sending email with HTML content.""" + result = _execute_reaction_logic( + reaction_name="send_email", + reaction_config={ + "recipient": "test@example.com", + "subject": "HTML Test", + "body": "

HTML Content

This is a test.

", + }, + trigger_data={}, + area=self.area, + ) + + self.assertTrue(result["sent"]) + self.assertEqual(len(mail.outbox), 1) + # Django's send_mail sends plain text, not HTML + self.assertIn("HTML Content", mail.outbox[0].body) + + def test_send_email_default_subject(self): + """Test send_email uses default subject when not provided.""" + result = _execute_reaction_logic( + reaction_name="send_email", + reaction_config={ + "recipient": "test@example.com", + # subject not provided - should use default + "body": "Test body", + }, + trigger_data={}, + area=self.area, + ) + + self.assertTrue(result["sent"]) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].subject, "AREA Notification") + + def test_send_email_default_body(self): + """Test send_email uses default body when not provided.""" + result = _execute_reaction_logic( + reaction_name="send_email", + reaction_config={ + "recipient": "test@example.com", + "subject": "Test", + # body not provided - should use default + }, + trigger_data={}, + area=self.area, + ) + + self.assertTrue(result["sent"]) + self.assertEqual(len(mail.outbox), 1) + self.assertIn("automated notification", mail.outbox[0].body) diff --git a/backend/automations/tests/test_execution_model.py b/backend/automations/tests/test_execution_model.py index d0e35c27..e7b89cb5 100755 --- a/backend/automations/tests/test_execution_model.py +++ b/backend/automations/tests/test_execution_model.py @@ -288,37 +288,3 @@ def test_retry_count_increment(self): execution.retry_count += 1 execution.save() self.assertEqual(execution.retry_count, 2) - - def test_trigger_data_and_result_data_json(self): - """Test storing complex JSON in trigger_data and result_data.""" - complex_trigger = { - "webhook": { - "event": "issue_created", - "repository": "user/repo", - "issue": { - "number": 42, - "title": "Bug report", - "labels": ["bug", "priority-high"], - }, - } - } - - complex_result = { - "slack_response": {"ok": True, "message_id": "1234.5678"}, - "timestamp": "2025-01-01T12:00:00Z", - } - - execution = Execution.objects.create( - area=self.area, - external_event_id="test_json", - trigger_data=complex_trigger, - ) - - execution.mark_started() - execution.mark_success(complex_result) - - # Refresh from DB - execution.refresh_from_db() - - self.assertEqual(execution.trigger_data, complex_trigger) - self.assertEqual(execution.result_data, complex_result) diff --git a/backend/automations/tests/test_github_reactions.py b/backend/automations/tests/test_github_reactions.py new file mode 100644 index 00000000..1eafa840 --- /dev/null +++ b/backend/automations/tests/test_github_reactions.py @@ -0,0 +1,277 @@ +""" +Tests for GitHub reaction execution. +Tests GitHub reactions through _execute_reaction_logic. +""" + +from unittest.mock import MagicMock, patch + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from automations.models import Action, Area, Reaction, Service +from automations.tasks import _execute_reaction_logic + +User = get_user_model() + + +class GitHubReactionTests(TestCase): + """Test GitHub reaction execution.""" + + def setUp(self): + """Set up test fixtures.""" + self.user = User.objects.create_user( + username="testuser", email="test@example.com", password="testpass123" + ) + + # Create GitHub service + self.github_service = Service.objects.create( + name="github", description="GitHub Service" + ) + + # Create Action and Reaction (required by Area model) + self.action = Action.objects.create( + service=self.github_service, + name="test_action", + description="Test action", + ) + + self.reaction = Reaction.objects.create( + service=self.github_service, + name="test_reaction", + description="Test reaction", + ) + + # Create a test automation area + self.area = Area.objects.create( + name="Test GitHub Area", + owner=self.user, + action=self.action, + reaction=self.reaction, + status=Area.Status.ACTIVE, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.tasks.requests.post") + def test_github_create_issue_success(self, mock_post, mock_get_token): + """Test successful GitHub issue creation.""" + mock_get_token.return_value = "test_github_token" + + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.json.return_value = { + "number": 42, + "html_url": "https://github.com/owner/repo/issues/42", + } + mock_post.return_value = mock_response + + result = _execute_reaction_logic( + reaction_name="github_create_issue", + reaction_config={ + "repository": "owner/repo", + "title": "Test Issue", + "body": "Test issue body", + }, + trigger_data={}, + area=self.area, + ) + + # Check result + self.assertTrue(result["success"]) + self.assertEqual(result["issue_number"], 42) + self.assertEqual(result["repository"], "owner/repo") + self.assertIn("github.com/owner/repo/issues/42", result["issue_url"]) + + # Verify API was called correctly + mock_get_token.assert_called_once_with(self.user, "github") + mock_post.assert_called_once() + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + def test_github_create_issue_missing_config(self, mock_get_token): + """Test github_create_issue with missing repository.""" + mock_get_token.return_value = "test_token" + + with self.assertRaisesMessage( + ValueError, "Repository is required for github_create_issue" + ): + _execute_reaction_logic( + reaction_name="github_create_issue", + reaction_config={ + "title": "Test Issue", + "body": "Test body", + }, + trigger_data={}, + area=self.area, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + def test_github_create_issue_no_token(self, mock_get_token): + """Test github_create_issue when user has no GitHub token.""" + mock_get_token.return_value = None + + with self.assertRaisesMessage(ValueError, "No valid GitHub token"): + _execute_reaction_logic( + reaction_name="github_create_issue", + reaction_config={ + "repository": "owner/repo", + "title": "Test Issue", + }, + trigger_data={}, + area=self.area, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.tasks.requests.post") + def test_github_create_issue_invalid_token(self, mock_post, mock_get_token): + """Test github_create_issue with invalid/expired token.""" + mock_get_token.return_value = "invalid_token" + + mock_response = MagicMock() + mock_response.status_code = 401 + mock_response.text = "Bad credentials" + mock_post.return_value = mock_response + + with self.assertRaisesMessage(ValueError, "GitHub authentication failed"): + _execute_reaction_logic( + reaction_name="github_create_issue", + reaction_config={ + "repository": "owner/repo", + "title": "Test Issue", + }, + trigger_data={}, + area=self.area, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.tasks.requests.post") + def test_github_create_issue_repo_not_found(self, mock_post, mock_get_token): + """Test github_create_issue with non-existent repository.""" + mock_get_token.return_value = "valid_token" + + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.text = "Not Found" + mock_post.return_value = mock_response + + with self.assertRaisesMessage( + ValueError, "Repository owner/repo not found or no access" + ): + _execute_reaction_logic( + reaction_name="github_create_issue", + reaction_config={ + "repository": "owner/repo", + "title": "Test Issue", + }, + trigger_data={}, + area=self.area, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.tasks.requests.post") + def test_github_create_issue_rate_limit(self, mock_post, mock_get_token): + """Test github_create_issue when rate limit is exceeded.""" + mock_get_token.return_value = "valid_token" + + mock_response = MagicMock() + mock_response.status_code = 403 + mock_response.text = "API rate limit exceeded" + mock_post.return_value = mock_response + + with self.assertRaisesMessage( + ValueError, "GitHub API rate limit exceeded or access forbidden" + ): + _execute_reaction_logic( + reaction_name="github_create_issue", + reaction_config={ + "repository": "owner/repo", + "title": "Test Issue", + }, + trigger_data={}, + area=self.area, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.tasks.requests.post") + def test_github_create_issue_with_labels(self, mock_post, mock_get_token): + """Test github_create_issue with labels.""" + mock_get_token.return_value = "test_token" + + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.json.return_value = { + "number": 42, + "html_url": "https://github.com/owner/repo/issues/42", + } + mock_post.return_value = mock_response + + result = _execute_reaction_logic( + reaction_name="github_create_issue", + reaction_config={ + "repository": "owner/repo", + "title": "Test Issue", + "body": "Test body", + "labels": ["bug", "urgent"], + }, + trigger_data={}, + area=self.area, + ) + + self.assertTrue(result["success"]) + + # Verify labels were included in API call + call_args = mock_post.call_args + self.assertIn("labels", call_args[1]["json"]) + self.assertEqual(call_args[1]["json"]["labels"], ["bug", "urgent"]) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.tasks.requests.post") + def test_github_create_issue_with_assignees(self, mock_post, mock_get_token): + """Test github_create_issue with assignees.""" + mock_get_token.return_value = "test_token" + + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.json.return_value = { + "number": 42, + "html_url": "https://github.com/owner/repo/issues/42", + } + mock_post.return_value = mock_response + + result = _execute_reaction_logic( + reaction_name="github_create_issue", + reaction_config={ + "repository": "owner/repo", + "title": "Test Issue", + "body": "Test body", + "assignees": ["testuser"], + }, + trigger_data={}, + area=self.area, + ) + + self.assertTrue(result["success"]) + + # Verify assignees were included in API call + call_args = mock_post.call_args + self.assertIn("assignees", call_args[1]["json"]) + self.assertEqual(call_args[1]["json"]["assignees"], ["testuser"]) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.tasks.requests.post") + def test_github_create_issue_timeout(self, mock_post, mock_get_token): + """Test github_create_issue when API times out.""" + mock_get_token.return_value = "test_token" + + import requests + + mock_post.side_effect = requests.exceptions.Timeout() + + with self.assertRaisesMessage(ValueError, "GitHub API request timed out"): + _execute_reaction_logic( + reaction_name="github_create_issue", + reaction_config={ + "repository": "owner/repo", + "title": "Test Issue", + }, + trigger_data={}, + area=self.area, + ) diff --git a/backend/automations/tests/test_gmail_reactions.py b/backend/automations/tests/test_gmail_reactions.py new file mode 100644 index 00000000..309a338a --- /dev/null +++ b/backend/automations/tests/test_gmail_reactions.py @@ -0,0 +1,270 @@ +""" +Tests for Gmail reaction execution. +Tests Gmail reactions through _execute_reaction_logic. +""" + +from unittest.mock import MagicMock, patch + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from automations.models import Action, Area, Reaction, Service +from automations.tasks import _execute_reaction_logic + +User = get_user_model() + + +class GmailReactionTests(TestCase): + """Test Gmail reaction execution.""" + + def setUp(self): + """Set up test fixtures.""" + self.user = User.objects.create_user( + username="testuser", email="test@example.com", password="testpass123" + ) + + # Create Gmail/Google service + self.gmail_service = Service.objects.create( + name="gmail", description="Gmail Service" + ) + + # Create Action and Reaction (required by Area model) + self.action = Action.objects.create( + service=self.gmail_service, + name="test_action", + description="Test action", + ) + + self.reaction = Reaction.objects.create( + service=self.gmail_service, + name="test_reaction", + description="Test reaction", + ) + + # Create a test automation area + self.area = Area.objects.create( + name="Test Gmail Area", + owner=self.user, + action=self.action, + reaction=self.reaction, + status=Area.Status.ACTIVE, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.gmail_helper.send_email") + def test_gmail_send_email_success(self, mock_send_email, mock_get_token): + """Test successful Gmail email sending.""" + mock_get_token.return_value = "test_google_token" + mock_send_email.return_value = {"id": "msg_123456"} + + result = _execute_reaction_logic( + reaction_name="gmail_send_email", + reaction_config={ + "to": "recipient@example.com", + "subject": "Test Subject", + "body": "Test email body", + }, + trigger_data={}, + area=self.area, + ) + + # Check result + self.assertTrue(result["success"]) + self.assertEqual(result["to"], "recipient@example.com") + self.assertEqual(result["subject"], "Test Subject") + self.assertEqual(result["message_id"], "msg_123456") + + # Verify API was called correctly + mock_get_token.assert_called_once_with(self.user, "google") + mock_send_email.assert_called_once_with( + "test_google_token", + "recipient@example.com", + "Test Subject", + "Test email body", + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + def test_gmail_send_email_missing_recipient(self, mock_get_token): + """Test gmail_send_email with missing recipient.""" + mock_get_token.return_value = "test_token" + + with self.assertRaisesMessage( + ValueError, "Recipient email is required for gmail_send_email" + ): + _execute_reaction_logic( + reaction_name="gmail_send_email", + reaction_config={ + "subject": "Test", + "body": "Test", + }, + trigger_data={}, + area=self.area, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + def test_gmail_send_email_no_token(self, mock_get_token): + """Test gmail_send_email when user has no Google token.""" + mock_get_token.return_value = None + + with self.assertRaisesMessage(ValueError, "No valid Google token"): + _execute_reaction_logic( + reaction_name="gmail_send_email", + reaction_config={ + "to": "test@example.com", + "subject": "Test", + "body": "Test", + }, + trigger_data={}, + area=self.area, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.gmail_helper.send_email") + def test_gmail_send_email_api_error(self, mock_send_email, mock_get_token): + """Test gmail_send_email when API fails.""" + mock_get_token.return_value = "test_token" + mock_send_email.side_effect = Exception("API error") + + with self.assertRaisesMessage(ValueError, "Gmail send failed"): + _execute_reaction_logic( + reaction_name="gmail_send_email", + reaction_config={ + "to": "test@example.com", + "subject": "Test", + "body": "Test", + }, + trigger_data={}, + area=self.area, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.gmail_helper.send_email") + def test_gmail_send_email_default_subject(self, mock_send_email, mock_get_token): + """Test gmail_send_email uses default subject when not provided.""" + mock_get_token.return_value = "test_token" + mock_send_email.return_value = {"id": "msg_123"} + + result = _execute_reaction_logic( + reaction_name="gmail_send_email", + reaction_config={ + "to": "test@example.com", + # subject not provided + "body": "Test", + }, + trigger_data={}, + area=self.area, + ) + + self.assertTrue(result["success"]) + # Verify default subject was used + call_args = mock_send_email.call_args[0] + self.assertEqual(call_args[2], "AREA Notification") + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.gmail_helper.mark_message_read") + def test_gmail_mark_read_success(self, mock_mark_read, mock_get_token): + """Test successful Gmail mark as read.""" + mock_get_token.return_value = "test_token" + + result = _execute_reaction_logic( + reaction_name="gmail_mark_read", + reaction_config={ + "message_id": "msg_123", + }, + trigger_data={}, + area=self.area, + ) + + self.assertTrue(result["success"]) + self.assertEqual(result["message_id"], "msg_123") + + mock_mark_read.assert_called_once_with("test_token", "msg_123") + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.gmail_helper.mark_message_read") + def test_gmail_mark_read_from_trigger_data(self, mock_mark_read, mock_get_token): + """Test gmail_mark_read getting message_id from trigger_data.""" + mock_get_token.return_value = "test_token" + + result = _execute_reaction_logic( + reaction_name="gmail_mark_read", + reaction_config={}, # No message_id in config + trigger_data={"message_id": "msg_from_trigger"}, + area=self.area, + ) + + self.assertTrue(result["success"]) + self.assertEqual(result["message_id"], "msg_from_trigger") + + mock_mark_read.assert_called_once_with("test_token", "msg_from_trigger") + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + def test_gmail_mark_read_no_message_id(self, mock_get_token): + """Test gmail_mark_read with no message_id.""" + mock_get_token.return_value = "test_token" + + with self.assertRaisesMessage(ValueError, "Message ID required"): + _execute_reaction_logic( + reaction_name="gmail_mark_read", + reaction_config={}, + trigger_data={}, + area=self.area, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.gmail_helper.add_label_to_message") + def test_gmail_add_label_success(self, mock_add_label, mock_get_token): + """Test successful Gmail add label.""" + mock_get_token.return_value = "test_token" + + result = _execute_reaction_logic( + reaction_name="gmail_add_label", + reaction_config={ + "message_id": "msg_123", + "label": "Important", + }, + trigger_data={}, + area=self.area, + ) + + self.assertTrue(result["success"]) + self.assertEqual(result["message_id"], "msg_123") + self.assertEqual(result["label"], "Important") + + mock_add_label.assert_called_once_with("test_token", "msg_123", "Important") + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + def test_gmail_add_label_missing_params(self, mock_get_token): + """Test gmail_add_label with missing parameters.""" + mock_get_token.return_value = "test_token" + + with self.assertRaisesMessage( + ValueError, "Message ID and label required for gmail_add_label" + ): + _execute_reaction_logic( + reaction_name="gmail_add_label", + reaction_config={ + "message_id": "msg_123", + # label missing + }, + trigger_data={}, + area=self.area, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.gmail_helper.add_label_to_message") + def test_gmail_add_label_api_error(self, mock_add_label, mock_get_token): + """Test gmail_add_label when API fails.""" + mock_get_token.return_value = "test_token" + mock_add_label.side_effect = Exception("Label not found") + + with self.assertRaisesMessage(ValueError, "Gmail add_label failed"): + _execute_reaction_logic( + reaction_name="gmail_add_label", + reaction_config={ + "message_id": "msg_123", + "label": "NonExistent", + }, + trigger_data={}, + area=self.area, + ) diff --git a/backend/automations/tests/test_init_services_command.py b/backend/automations/tests/test_init_services_command.py index 609ca40d..2160b0c9 100755 --- a/backend/automations/tests/test_init_services_command.py +++ b/backend/automations/tests/test_init_services_command.py @@ -35,7 +35,8 @@ def test_command_creates_services(self): "gmail", "email", "slack", - "webhook", + "weather", + "google_calendar", ] for service_name in expected_services: @@ -59,7 +60,7 @@ def test_command_creates_actions(self): ("github", "github_new_issue"), ("github", "github_new_pr"), ("gmail", "gmail_new_email"), - ("webhook", "webhook_trigger"), + ("weather", "weather_rain_detected"), ] for service_name, action_name in expected_actions: @@ -79,7 +80,7 @@ def test_command_creates_reactions(self): ("github", "github_create_issue"), ("email", "send_email"), ("slack", "slack_send_message"), - ("webhook", "webhook_post"), + ("spotify", "spotify_play_track"), ] for service_name, reaction_name in expected_reactions: diff --git a/backend/automations/tests/test_notion_reactions.py b/backend/automations/tests/test_notion_reactions.py new file mode 100644 index 00000000..5473d5d2 --- /dev/null +++ b/backend/automations/tests/test_notion_reactions.py @@ -0,0 +1,368 @@ +""" +Tests for Notion reaction execution. +Tests Notion reactions through _execute_reaction_logic. +""" + +from unittest.mock import MagicMock, patch + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from automations.models import Action, Area, Reaction, Service +from automations.tasks import _execute_reaction_logic + +User = get_user_model() + + +class NotionReactionTests(TestCase): + """Test Notion reaction execution.""" + + def setUp(self): + """Set up test fixtures.""" + self.user = User.objects.create_user( + username="testuser", email="test@example.com", password="testpass123" + ) + + # Create Notion service + self.notion_service = Service.objects.create( + name="notion", description="Notion Service" + ) + + # Create Action and Reaction (required by Area model) + self.action = Action.objects.create( + service=self.notion_service, + name="test_action", + description="Test action", + ) + + self.reaction = Reaction.objects.create( + service=self.notion_service, + name="test_reaction", + description="Test reaction", + ) + + # Create a test automation area + self.area = Area.objects.create( + name="Test Notion Area", + owner=self.user, + action=self.action, + reaction=self.reaction, + status=Area.Status.ACTIVE, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.tasks.requests.post") + def test_notion_create_page_success(self, mock_post, mock_get_token): + """Test successful Notion page creation.""" + mock_get_token.return_value = "test_notion_token" + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": "page_123", + "url": "https://notion.so/page_123", + } + mock_post.return_value = mock_response + + result = _execute_reaction_logic( + reaction_name="notion_create_page", + reaction_config={ + "title": "Test Page", + "content": "Test content", + }, + trigger_data={}, + area=self.area, + ) + + # Check result + self.assertTrue(result["success"]) + self.assertEqual(result["title"], "Test Page") + self.assertEqual(result["page_id"], "page_123") + self.assertIn("notion.so", result["page_url"]) + + # Verify API was called correctly + mock_get_token.assert_called_once_with(self.user, "notion") + mock_post.assert_called_once() + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + def test_notion_create_page_no_token(self, mock_get_token): + """Test notion_create_page when user has no Notion token.""" + mock_get_token.return_value = None + + with self.assertRaisesMessage(ValueError, "No valid Notion token"): + _execute_reaction_logic( + reaction_name="notion_create_page", + reaction_config={"title": "Test"}, + trigger_data={}, + area=self.area, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.tasks.requests.post") + def test_notion_create_page_with_parent(self, mock_post, mock_get_token): + """Test notion_create_page with parent page.""" + mock_get_token.return_value = "test_token" + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": "page_123", + "url": "https://notion.so/page_123", + } + mock_post.return_value = mock_response + + with patch( + "automations.helpers.notion_helper.extract_notion_uuid" + ) as mock_extract: + mock_extract.return_value = "parent_uuid_123" + + result = _execute_reaction_logic( + reaction_name="notion_create_page", + reaction_config={ + "parent_id": "https://notion.so/parent_page", + "title": "Child Page", + }, + trigger_data={}, + area=self.area, + ) + + self.assertTrue(result["success"]) + + # Verify parent_id was processed + call_args = mock_post.call_args + payload = call_args[1]["json"] + self.assertEqual(payload["parent"]["page_id"], "parent_uuid_123") + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.tasks.requests.post") + def test_notion_create_page_api_error(self, mock_post, mock_get_token): + """Test notion_create_page when API fails.""" + mock_get_token.return_value = "test_token" + + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.text = "Invalid request" + mock_post.return_value = mock_response + + with self.assertRaisesMessage(ValueError, "Notion API error"): + _execute_reaction_logic( + reaction_name="notion_create_page", + reaction_config={"title": "Test"}, + trigger_data={}, + area=self.area, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.tasks.requests.patch") + def test_notion_update_page_success(self, mock_patch, mock_get_token): + """Test successful Notion page update.""" + mock_get_token.return_value = "test_token" + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_patch.return_value = mock_response + + with patch( + "automations.helpers.notion_helper.extract_notion_uuid" + ) as mock_extract: + mock_extract.return_value = "page_uuid_123" + + result = _execute_reaction_logic( + reaction_name="notion_update_page", + reaction_config={ + "page_id": "page_uuid_123", + "title": "Updated Title", + "content": "New content", + }, + trigger_data={}, + area=self.area, + ) + + self.assertTrue(result["success"]) + self.assertEqual(result["page_id"], "page_uuid_123") + self.assertTrue(result["content_appended"]) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + def test_notion_update_page_missing_page_id(self, mock_get_token): + """Test notion_update_page with missing page_id.""" + mock_get_token.return_value = "test_token" + + with self.assertRaisesMessage( + ValueError, "page_id is required for notion_update_page" + ): + _execute_reaction_logic( + reaction_name="notion_update_page", + reaction_config={"title": "Updated"}, + trigger_data={}, + area=self.area, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.notion_helper.find_notion_page_by_name") + def test_notion_update_page_by_name(self, mock_find_page, mock_get_token): + """Test notion_update_page finding page by name.""" + mock_get_token.return_value = "test_token" + mock_find_page.return_value = "found_page_uuid" + + with patch( + "automations.helpers.notion_helper.extract_notion_uuid" + ) as mock_extract: + mock_extract.return_value = None # Simulate URL extraction failing + + with patch("automations.tasks.requests.patch") as mock_patch: + mock_patch.return_value = MagicMock(status_code=200) + + result = _execute_reaction_logic( + reaction_name="notion_update_page", + reaction_config={ + "page_id": "My Page Name", + "title": "Updated", + }, + trigger_data={}, + area=self.area, + ) + + self.assertTrue(result["success"]) + mock_find_page.assert_called_once_with("test_token", "My Page Name") + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.tasks.requests.post") + def test_notion_create_database_item_success(self, mock_post, mock_get_token): + """Test successful Notion database item creation.""" + mock_get_token.return_value = "test_token" + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": "item_123", + "url": "https://notion.so/item_123", + } + mock_post.return_value = mock_response + + with patch( + "automations.helpers.notion_helper.extract_notion_uuid" + ) as mock_extract: + mock_extract.return_value = "database_uuid_123" + + result = _execute_reaction_logic( + reaction_name="notion_create_database_item", + reaction_config={ + "database_id": "database_uuid_123", + "item_name": "New Item", + "properties": {"Status": "Active"}, + }, + trigger_data={}, + area=self.area, + ) + + self.assertTrue(result["success"]) + self.assertEqual(result["item_name"], "New Item") + self.assertEqual(result["database_id"], "database_uuid_123") + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + def test_notion_create_database_item_missing_database_id(self, mock_get_token): + """Test notion_create_database_item with missing database_id.""" + mock_get_token.return_value = "test_token" + + with self.assertRaisesMessage( + ValueError, "database_id is required for notion_create_database_item" + ): + _execute_reaction_logic( + reaction_name="notion_create_database_item", + reaction_config={"item_name": "Test"}, + trigger_data={}, + area=self.area, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + def test_notion_create_database_item_missing_item_name(self, mock_get_token): + """Test notion_create_database_item with missing item_name.""" + mock_get_token.return_value = "test_token" + + with patch( + "automations.helpers.notion_helper.extract_notion_uuid" + ) as mock_extract: + mock_extract.return_value = "db_uuid" + + with self.assertRaisesMessage( + ValueError, "item_name is required for notion_create_database_item" + ): + _execute_reaction_logic( + reaction_name="notion_create_database_item", + reaction_config={ + "database_id": "db_uuid", + "item_name": "", # Empty name + }, + trigger_data={}, + area=self.area, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.tasks.requests.post") + def test_notion_create_database_item_with_json_properties( + self, mock_post, mock_get_token + ): + """Test notion_create_database_item with JSON string properties.""" + mock_get_token.return_value = "test_token" + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": "item_123", + "url": "https://notion.so/item_123", + } + mock_post.return_value = mock_response + + with patch( + "automations.helpers.notion_helper.extract_notion_uuid" + ) as mock_extract: + mock_extract.return_value = "db_uuid" + + result = _execute_reaction_logic( + reaction_name="notion_create_database_item", + reaction_config={ + "database_id": "db_uuid", + "item_name": "Test", + "properties": '{"Priority": "High"}', # JSON string + }, + trigger_data={}, + area=self.area, + ) + + self.assertTrue(result["success"]) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.notion_helper.find_notion_database_by_name") + @patch("automations.tasks.requests.post") + def test_notion_create_database_item_by_name( + self, mock_post, mock_find_db, mock_get_token + ): + """Test notion_create_database_item finding database by name.""" + mock_get_token.return_value = "test_token" + mock_find_db.return_value = "found_db_uuid" + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": "item_123", + "url": "https://notion.so/item_123", + } + mock_post.return_value = mock_response + + with patch( + "automations.helpers.notion_helper.extract_notion_uuid" + ) as mock_extract: + mock_extract.return_value = None # Simulate UUID extraction failing + + result = _execute_reaction_logic( + reaction_name="notion_create_database_item", + reaction_config={ + "database_id": "My Database", + "item_name": "New Item", + }, + trigger_data={}, + area=self.area, + ) + + self.assertTrue(result["success"]) + mock_find_db.assert_called_once_with("test_token", "My Database") diff --git a/backend/automations/tests/test_slack_reactions.py b/backend/automations/tests/test_slack_reactions.py new file mode 100644 index 00000000..9b509490 --- /dev/null +++ b/backend/automations/tests/test_slack_reactions.py @@ -0,0 +1,267 @@ +""" +Tests for Slack reaction execution. +Tests Slack reactions through _execute_reaction_logic. +""" + +from unittest.mock import MagicMock, patch + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from automations.models import Action, Area, Reaction, Service +from automations.tasks import _execute_reaction_logic + +User = get_user_model() + + +class SlackReactionTests(TestCase): + """Test Slack reaction execution.""" + + def setUp(self): + """Set up test fixtures.""" + self.user = User.objects.create_user( + username="testuser", email="test@example.com", password="testpass123" + ) + + # Create Slack service + self.slack_service = Service.objects.create( + name="slack", description="Slack Service" + ) + + # Create Action and Reaction (required by Area model) + self.action = Action.objects.create( + service=self.slack_service, + name="test_action", + description="Test action", + ) + + self.reaction = Reaction.objects.create( + service=self.slack_service, + name="test_reaction", + description="Test reaction", + ) + + # Create a test automation area + self.area = Area.objects.create( + name="Test Slack Area", + owner=self.user, + action=self.action, + reaction=self.reaction, + status=Area.Status.ACTIVE, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.slack_helper.post_message") + def test_slack_send_message_success(self, mock_post_message, mock_get_token): + """Test successful Slack message sending.""" + mock_get_token.return_value = "test_slack_token" + mock_post_message.return_value = {"ts": "1234567890.123456"} + + result = _execute_reaction_logic( + reaction_name="slack_send_message", + reaction_config={ + "channel": "#general", + "message": "Test message", + }, + trigger_data={}, + area=self.area, + ) + + # Check result + self.assertTrue(result["success"]) + self.assertEqual(result["channel"], "#general") + self.assertEqual(result["message"], "Test message") + self.assertEqual(result["message_ts"], "1234567890.123456") + + # Verify API was called correctly + mock_get_token.assert_called_once_with(self.user, "slack") + mock_post_message.assert_called_once_with( + "test_slack_token", "#general", "Test message" + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + def test_slack_send_message_missing_channel(self, mock_get_token): + """Test slack_send_message with missing channel.""" + mock_get_token.return_value = "test_token" + + with self.assertRaisesMessage( + ValueError, "Channel is required for slack_send_message" + ): + _execute_reaction_logic( + reaction_name="slack_send_message", + reaction_config={ + "message": "Test message", + }, + trigger_data={}, + area=self.area, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + def test_slack_send_message_no_token(self, mock_get_token): + """Test slack_send_message when user has no Slack token.""" + mock_get_token.return_value = None + + with self.assertRaisesMessage(ValueError, "No valid Slack token"): + _execute_reaction_logic( + reaction_name="slack_send_message", + reaction_config={ + "channel": "#general", + "message": "Test", + }, + trigger_data={}, + area=self.area, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.slack_helper.post_message") + def test_slack_send_message_api_error(self, mock_post_message, mock_get_token): + """Test slack_send_message when API fails.""" + mock_get_token.return_value = "test_token" + mock_post_message.side_effect = Exception("channel_not_found") + + with self.assertRaisesMessage(ValueError, "Slack send_message failed"): + _execute_reaction_logic( + reaction_name="slack_send_message", + reaction_config={ + "channel": "#nonexistent", + "message": "Test", + }, + trigger_data={}, + area=self.area, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.slack_helper.post_message") + def test_slack_send_message_default_message( + self, mock_post_message, mock_get_token + ): + """Test slack_send_message uses default message when not provided.""" + mock_get_token.return_value = "test_token" + mock_post_message.return_value = {"ts": "1234567890.123456"} + + result = _execute_reaction_logic( + reaction_name="slack_send_message", + reaction_config={ + "channel": "#general", + # message not provided - should use default + }, + trigger_data={}, + area=self.area, + ) + + self.assertTrue(result["success"]) + # Verify default message was used + mock_post_message.assert_called_once() + call_args = mock_post_message.call_args[0] + self.assertEqual(call_args[2], "AREA triggered") + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.slack_helper.post_message") + def test_slack_send_alert_success(self, mock_post_message, mock_get_token): + """Test successful Slack alert sending.""" + mock_get_token.return_value = "test_token" + mock_post_message.return_value = {"ts": "1234567890.123456"} + + result = _execute_reaction_logic( + reaction_name="slack_send_alert", + reaction_config={ + "channel": "#alerts", + "alert_type": "warning", + "title": "System Alert", + "details": "CPU usage high", + }, + trigger_data={}, + area=self.area, + ) + + self.assertTrue(result["success"]) + self.assertEqual(result["channel"], "#alerts") + self.assertEqual(result["alert_type"], "warning") + self.assertEqual(result["title"], "System Alert") + + # Verify API was called with attachments + mock_post_message.assert_called_once() + call_args = mock_post_message.call_args + self.assertIn("attachments", call_args[1]) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.slack_helper.post_message") + def test_slack_send_alert_info_type(self, mock_post_message, mock_get_token): + """Test slack_send_alert with info alert type.""" + mock_get_token.return_value = "test_token" + mock_post_message.return_value = {"ts": "1234567890.123456"} + + result = _execute_reaction_logic( + reaction_name="slack_send_alert", + reaction_config={ + "channel": "#general", + "alert_type": "info", + "title": "Info Alert", + }, + trigger_data={}, + area=self.area, + ) + + self.assertTrue(result["success"]) + self.assertEqual(result["alert_type"], "info") + + # Verify color is "good" for info + call_args = mock_post_message.call_args + attachment = call_args[1]["attachments"][0] + self.assertEqual(attachment["color"], "good") + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.slack_helper.post_message") + def test_slack_send_alert_error_type(self, mock_post_message, mock_get_token): + """Test slack_send_alert with error alert type.""" + mock_get_token.return_value = "test_token" + mock_post_message.return_value = {"ts": "1234567890.123456"} + + result = _execute_reaction_logic( + reaction_name="slack_send_alert", + reaction_config={ + "channel": "#errors", + "alert_type": "error", + "title": "Critical Error", + }, + trigger_data={}, + area=self.area, + ) + + self.assertTrue(result["success"]) + + # Verify color is "danger" for error + call_args = mock_post_message.call_args + attachment = call_args[1]["attachments"][0] + self.assertEqual(attachment["color"], "danger") + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.slack_helper.post_message") + def test_slack_post_update_success(self, mock_post_message, mock_get_token): + """Test successful Slack update posting.""" + mock_get_token.return_value = "test_token" + mock_post_message.return_value = {"ts": "1234567890.123456"} + + result = _execute_reaction_logic( + reaction_name="slack_post_update", + reaction_config={ + "channel": "#updates", + "title": "Deployment Update", + "status": "Completed", + "details": "Version 1.2.3 deployed", + }, + trigger_data={}, + area=self.area, + ) + + self.assertTrue(result["success"]) + self.assertEqual(result["channel"], "#updates") + self.assertEqual(result["title"], "Deployment Update") + self.assertEqual(result["status"], "Completed") + + # Verify message formatting + mock_post_message.assert_called_once() + call_args = mock_post_message.call_args[0] + message_text = call_args[2] + self.assertIn("Deployment Update", message_text) + self.assertIn("Completed", message_text) diff --git a/backend/automations/tests/test_spotify_reactions.py b/backend/automations/tests/test_spotify_reactions.py new file mode 100644 index 00000000..5fcf5a86 --- /dev/null +++ b/backend/automations/tests/test_spotify_reactions.py @@ -0,0 +1,308 @@ +""" +Tests for Spotify reaction execution. +Tests Spotify reactions through _execute_reaction_logic. +""" + +from unittest.mock import MagicMock, patch + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from automations.models import Action, Area, Reaction, Service +from automations.tasks import _execute_reaction_logic + +User = get_user_model() + + +class SpotifyReactionTests(TestCase): + """Test Spotify reaction execution.""" + + def setUp(self): + """Set up test fixtures.""" + self.user = User.objects.create_user( + username="testuser", email="test@example.com", password="testpass123" + ) + + # Create Spotify service + self.spotify_service = Service.objects.create( + name="spotify", description="Spotify Service" + ) + + # Create Action and Reaction (required by Area model) + self.action = Action.objects.create( + service=self.spotify_service, + name="test_action", + description="Test action", + ) + + self.reaction = Reaction.objects.create( + service=self.spotify_service, + name="test_reaction", + description="Test reaction", + ) + + # Create a test automation area + self.area = Area.objects.create( + name="Test Spotify Area", + owner=self.user, + action=self.action, + reaction=self.reaction, + status=Area.Status.ACTIVE, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.spotify_helper.play_track") + def test_spotify_play_track_success(self, mock_play_track, mock_get_token): + """Test successful Spotify track playback.""" + mock_get_token.return_value = "test_spotify_token" + mock_play_track.return_value = {"success": True} + + result = _execute_reaction_logic( + reaction_name="spotify_play_track", + reaction_config={ + "track_uri": "spotify:track:3n3Ppam7vgaVa1iaRUc9Lp", + "position_ms": 0, + }, + trigger_data={}, + area=self.area, + ) + + # Check result + self.assertEqual(result["success"], True) + + # Verify API was called correctly + mock_get_token.assert_called_once_with(self.user, "spotify") + mock_play_track.assert_called_once_with( + "test_spotify_token", "spotify:track:3n3Ppam7vgaVa1iaRUc9Lp", 0 + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.spotify_helper.play_track") + def test_spotify_play_track_from_url(self, mock_play_track, mock_get_token): + """Test Spotify track playback from URL (converts to URI).""" + mock_get_token.return_value = "test_token" + mock_play_track.return_value = {"success": True} + + result = _execute_reaction_logic( + reaction_name="spotify_play_track", + reaction_config={ + "track_uri": "https://open.spotify.com/track/3n3Ppam7vgaVa1iaRUc9Lp", + }, + trigger_data={}, + area=self.area, + ) + + self.assertEqual(result["success"], True) + + # Verify URI conversion + call_args = mock_play_track.call_args[0] + self.assertEqual(call_args[1], "spotify:track:3n3Ppam7vgaVa1iaRUc9Lp") + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + def test_spotify_play_track_missing_uri(self, mock_get_token): + """Test spotify_play_track with missing track URI.""" + mock_get_token.return_value = "test_token" + + with self.assertRaisesMessage( + ValueError, "Track URI/URL is required for spotify_play_track" + ): + _execute_reaction_logic( + reaction_name="spotify_play_track", + reaction_config={}, + trigger_data={}, + area=self.area, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + def test_spotify_play_track_no_token(self, mock_get_token): + """Test spotify_play_track when user has no Spotify token.""" + mock_get_token.return_value = None + + with self.assertRaisesMessage(ValueError, "No valid Spotify token"): + _execute_reaction_logic( + reaction_name="spotify_play_track", + reaction_config={"track_uri": "spotify:track:123"}, + trigger_data={}, + area=self.area, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.spotify_helper.play_track") + def test_spotify_play_track_api_error(self, mock_play_track, mock_get_token): + """Test spotify_play_track when API fails.""" + mock_get_token.return_value = "test_token" + mock_play_track.side_effect = Exception("Track not found") + + with self.assertRaisesMessage(ValueError, "Spotify play_track failed"): + _execute_reaction_logic( + reaction_name="spotify_play_track", + reaction_config={"track_uri": "spotify:track:invalid"}, + trigger_data={}, + area=self.area, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.spotify_helper.pause_playback") + def test_spotify_pause_playback_success(self, mock_pause, mock_get_token): + """Test successful Spotify pause.""" + mock_get_token.return_value = "test_token" + mock_pause.return_value = {"success": True} + + result = _execute_reaction_logic( + reaction_name="spotify_pause_playback", + reaction_config={}, + trigger_data={}, + area=self.area, + ) + + self.assertEqual(result["success"], True) + mock_pause.assert_called_once_with("test_token") + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.spotify_helper.resume_playback") + def test_spotify_resume_playback_success(self, mock_resume, mock_get_token): + """Test successful Spotify resume.""" + mock_get_token.return_value = "test_token" + mock_resume.return_value = {"success": True} + + result = _execute_reaction_logic( + reaction_name="spotify_resume_playback", + reaction_config={}, + trigger_data={}, + area=self.area, + ) + + self.assertEqual(result["success"], True) + mock_resume.assert_called_once_with("test_token") + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.spotify_helper.skip_to_next") + def test_spotify_skip_next_success(self, mock_skip_next, mock_get_token): + """Test successful Spotify skip to next.""" + mock_get_token.return_value = "test_token" + mock_skip_next.return_value = {"success": True} + + result = _execute_reaction_logic( + reaction_name="spotify_skip_next", + reaction_config={}, + trigger_data={}, + area=self.area, + ) + + self.assertEqual(result["success"], True) + mock_skip_next.assert_called_once_with("test_token") + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.spotify_helper.skip_to_previous") + def test_spotify_skip_previous_success(self, mock_skip_prev, mock_get_token): + """Test successful Spotify skip to previous.""" + mock_get_token.return_value = "test_token" + mock_skip_prev.return_value = {"success": True} + + result = _execute_reaction_logic( + reaction_name="spotify_skip_previous", + reaction_config={}, + trigger_data={}, + area=self.area, + ) + + self.assertEqual(result["success"], True) + mock_skip_prev.assert_called_once_with("test_token") + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.spotify_helper.set_volume") + def test_spotify_set_volume_success(self, mock_set_volume, mock_get_token): + """Test successful Spotify volume change.""" + mock_get_token.return_value = "test_token" + mock_set_volume.return_value = {"success": True} + + result = _execute_reaction_logic( + reaction_name="spotify_set_volume", + reaction_config={"volume_percent": 75}, + trigger_data={}, + area=self.area, + ) + + self.assertEqual(result["success"], True) + mock_set_volume.assert_called_once_with("test_token", 75) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.spotify_helper.set_volume") + def test_spotify_set_volume_default(self, mock_set_volume, mock_get_token): + """Test Spotify volume with default value.""" + mock_get_token.return_value = "test_token" + mock_set_volume.return_value = {"success": True} + + result = _execute_reaction_logic( + reaction_name="spotify_set_volume", + reaction_config={}, # No volume specified + trigger_data={}, + area=self.area, + ) + + self.assertEqual(result["success"], True) + # Should use default of 50% + mock_set_volume.assert_called_once_with("test_token", 50) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.spotify_helper.create_playlist") + def test_spotify_create_playlist_success(self, mock_create, mock_get_token): + """Test successful Spotify playlist creation.""" + mock_get_token.return_value = "test_token" + mock_create.return_value = { + "success": True, + "playlist_id": "playlist_123", + "name": "My New Playlist", + } + + result = _execute_reaction_logic( + reaction_name="spotify_create_playlist", + reaction_config={ + "name": "My New Playlist", + "description": "Created by AREA", + "public": True, + }, + trigger_data={}, + area=self.area, + ) + + self.assertEqual(result["success"], True) + self.assertEqual(result["name"], "My New Playlist") + + mock_create.assert_called_once_with( + "test_token", "My New Playlist", "Created by AREA", True + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + def test_spotify_create_playlist_missing_name(self, mock_get_token): + """Test spotify_create_playlist with missing name.""" + mock_get_token.return_value = "test_token" + + with self.assertRaisesMessage( + ValueError, "Playlist name is required for spotify_create_playlist" + ): + _execute_reaction_logic( + reaction_name="spotify_create_playlist", + reaction_config={}, + trigger_data={}, + area=self.area, + ) + + @patch("users.oauth.manager.OAuthManager.get_valid_token") + @patch("automations.helpers.spotify_helper.create_playlist") + def test_spotify_create_playlist_default_values(self, mock_create, mock_get_token): + """Test spotify_create_playlist uses default values.""" + mock_get_token.return_value = "test_token" + mock_create.return_value = {"success": True} + + _execute_reaction_logic( + reaction_name="spotify_create_playlist", + reaction_config={"name": "Test Playlist"}, + trigger_data={}, + area=self.area, + ) + + # Verify default values + call_args = mock_create.call_args[0] + self.assertEqual(call_args[2], "") # default description + self.assertEqual(call_args[3], False) # default public=False diff --git a/backend/automations/tests/test_tasks.py b/backend/automations/tests/test_tasks.py index e74121da..e9dda098 100755 --- a/backend/automations/tests/test_tasks.py +++ b/backend/automations/tests/test_tasks.py @@ -214,7 +214,7 @@ def setUp(self): ) @freeze_time("2024-01-15 14:30:00") - @patch("automations.tasks.execute_reaction") + @patch("automations.tasks.execute_reaction_task") def test_check_timer_actions_triggers_at_correct_time(self, mock_execute): """Test that timer triggers at configured time.""" # Create area for 14:30 @@ -227,7 +227,7 @@ def test_check_timer_actions_triggers_at_correct_time(self, mock_execute): status=Area.Status.ACTIVE, ) - # Mock execute_reaction to prevent actual execution + # Mock execute_reaction_task to prevent actual execution mock_execute.delay.return_value = MagicMock(id="task-123") # Run the task @@ -242,7 +242,7 @@ def test_check_timer_actions_triggers_at_correct_time(self, mock_execute): self.assertEqual(executions.count(), 1) @freeze_time("2024-01-15 14:30:00") - @patch("automations.tasks.execute_reaction") + @patch("automations.tasks.execute_reaction_task") def test_check_timer_actions_idempotency(self, mock_execute): """Test that running twice at same time doesn't create duplicates.""" # Create area for 14:30 @@ -255,7 +255,7 @@ def test_check_timer_actions_idempotency(self, mock_execute): status=Area.Status.ACTIVE, ) - # Mock execute_reaction + # Mock execute_reaction_task mock_execute.delay.return_value = MagicMock(id="task-123") # Run task twice @@ -401,10 +401,10 @@ def setUp(self): status=Area.Status.ACTIVE, ) - @patch("automations.tasks.execute_reaction") + @patch("automations.tasks.execute_reaction_task") def test_test_execution_flow_success(self, mock_execute): """Test manual test execution flow.""" - # Mock execute_reaction to prevent actual execution + # Mock execute_reaction_task to prevent actual execution mock_execute.delay.return_value = MagicMock(id="task-123") result = test_execution_flow(self.area.pk) diff --git a/backend/automations/tests/test_timer_actions.py b/backend/automations/tests/test_timer_actions.py index 453eea7a..1059b385 100755 --- a/backend/automations/tests/test_timer_actions.py +++ b/backend/automations/tests/test_timer_actions.py @@ -124,8 +124,8 @@ def setUp(self): self.timer_service = Service.objects.create( name="timer", description="Timer service" ) - self.webhook_service = Service.objects.create( - name="webhook", description="Webhook service" + self.email_service = Service.objects.create( + name="email", description="Email service" ) # Create actions and reactions @@ -140,9 +140,9 @@ def setUp(self): description="Trigger at specific time weekly", ) self.reaction = Reaction.objects.create( - service=self.webhook_service, - name="webhook_post", - description="Send HTTP POST webhook", + service=self.email_service, + name="send_email", + description="Send an email", ) @freeze_time("2024-01-15 14:30:00") @@ -339,8 +339,8 @@ def setUp(self): self.timer_service = Service.objects.create( name="timer", description="Timer service" ) - self.webhook_service = Service.objects.create( - name="webhook", description="Webhook service" + self.email_service = Service.objects.create( + name="email", description="Email service" ) # Create actions and reactions @@ -350,9 +350,9 @@ def setUp(self): description="Trigger at specific time daily", ) self.reaction = Reaction.objects.create( - service=self.webhook_service, - name="webhook_post", - description="Send HTTP POST webhook", + service=self.email_service, + name="send_email", + description="Send an email", ) @freeze_time("2024-01-15 09:00:00") diff --git a/backend/automations/tests/test_webhooks.py b/backend/automations/tests/test_webhooks.py index 86d2d037..0a007ea1 100755 --- a/backend/automations/tests/test_webhooks.py +++ b/backend/automations/tests/test_webhooks.py @@ -180,8 +180,8 @@ def setUp(self): self.github_service = Service.objects.create( name="github", description="GitHub service" ) - self.webhook_service = Service.objects.create( - name="webhook", description="Webhook service" + self.email_service = Service.objects.create( + name="email", description="Email service" ) # Create actions @@ -198,9 +198,9 @@ def setUp(self): # Create reaction self.reaction = Reaction.objects.create( - service=self.webhook_service, - name="webhook_post", - description="Send webhook", + service=self.email_service, + name="send_email", + description="Send an email", ) def test_match_github_push_event(self): @@ -268,8 +268,8 @@ def setUp(self): self.github_service = Service.objects.create( name="github", description="GitHub service", status=Service.Status.ACTIVE ) - self.webhook_service = Service.objects.create( - name="webhook", description="Webhook service", status=Service.Status.ACTIVE + self.email_service = Service.objects.create( + name="email", description="Email service", status=Service.Status.ACTIVE ) # Create action and reaction @@ -279,9 +279,9 @@ def setUp(self): description="GitHub push event", ) self.reaction = Reaction.objects.create( - service=self.webhook_service, - name="webhook_post", - description="Send webhook", + service=self.email_service, + name="send_email", + description="Send an email", ) # Create area diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 8064ebd5..151ab642 100755 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -109,7 +109,7 @@ skips = ["B101"] # Skip assert_used test only # Note: settings and tests directories completely excluded from security scans [tool.coverage.run] -source = "." +source = ["."] omit = [ "*/migrations/*", "*/venv/*", diff --git a/backend/scripts/run_tests.sh b/backend/scripts/run_tests.sh new file mode 100755 index 00000000..9cc9c3b7 --- /dev/null +++ b/backend/scripts/run_tests.sh @@ -0,0 +1,325 @@ +#!/bin/bash + +# run_tests.sh - Comprehensive Django test runner with coverage +# This script runs all Django tests and generates coverage reports + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' # No Color + +# Get script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BACKEND_DIR="$(dirname "$SCRIPT_DIR")" +VENV_PATH="$BACKEND_DIR/venv" + +# Parse command line arguments +VERBOSITY=2 +FAILFAST=false +KEEPDB=false +PARALLEL=1 +SPECIFIC_TEST="" +SKIP_COVERAGE=false +HTML_REPORT=false + +print_usage() { + cat << EOF +${BOLD}Usage:${NC} $0 [OPTIONS] [TEST_PATH] + +${BOLD}Description:${NC} + Run Django tests with coverage reporting for the AREA automation system. + +${BOLD}Options:${NC} + -h, --help Show this help message + -v, --verbose Increase verbosity (1-3, default: 2) + -f, --failfast Stop on first test failure + -k, --keepdb Preserve test database between runs + -p, --parallel N Run tests in N parallel processes + -s, --skip-coverage Skip coverage report generation + -H, --html Generate HTML coverage report + -q, --quiet Minimal output (verbosity 0) + +${BOLD}Test Paths:${NC} + Without arguments: Run all tests + automations Run all automations tests + users Run all users tests + automations.tests.test_views + Run specific test module + automations.tests.test_views.ServiceViewSetTest + Run specific test class + automations.tests.test_views.ServiceViewSetTest.test_list_services + Run specific test method + +${BOLD}Examples:${NC} + # Run all tests with coverage + $0 + + # Run only automations tests with HTML report + $0 --html automations + + # Run specific test with failfast and keepdb + $0 -f -k automations.tests.test_serializers + + # Run tests in parallel without coverage + $0 --parallel 4 --skip-coverage + + # Quick run with minimal output + $0 -q -k automations.tests.test_views + +EOF +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + -h|--help) + print_usage + exit 0 + ;; + -v|--verbose) + VERBOSITY=3 + shift + ;; + -q|--quiet) + VERBOSITY=0 + shift + ;; + -f|--failfast) + FAILFAST=true + shift + ;; + -k|--keepdb) + KEEPDB=true + shift + ;; + -p|--parallel) + PARALLEL="$2" + shift 2 + ;; + -s|--skip-coverage) + SKIP_COVERAGE=true + shift + ;; + -H|--html) + HTML_REPORT=true + shift + ;; + -*) + echo -e "${RED}❌ Unknown option: $1${NC}" + print_usage + exit 1 + ;; + *) + SPECIFIC_TEST="$1" + shift + ;; + esac +done + +# Banner +echo -e "${BLUE}${BOLD}" +echo "╔════════════════════════════════════════════════════════════╗" +echo "║ 🧪 AREA Django Test Suite with Coverage ║" +echo "╚════════════════════════════════════════════════════════════╝" +echo -e "${NC}" + +# Check if we're in the backend directory +cd "$BACKEND_DIR" +echo -e "${CYAN}📂 Working directory: ${NC}$BACKEND_DIR" + +# Set environment to test mode BEFORE activating venv +export DJANGO_ENV=test +export DJANGO_SETTINGS_MODULE=area_project.settings + +# Activate virtual environment if it exists +if [ -d "$VENV_PATH" ]; then + echo -e "${CYAN}🐍 Activating virtual environment...${NC}" + + # Use the Python binary directly to avoid lib/lib64 issues + PYTHON_BIN="$VENV_PATH/bin/python" + + if [ -f "$PYTHON_BIN" ]; then + # Export Python path to use venv directly + export PATH="$VENV_PATH/bin:$PATH" + export VIRTUAL_ENV="$VENV_PATH" + # Unset PYTHONHOME to avoid conflicts + unset PYTHONHOME + echo -e "${GREEN}✅ Virtual environment activated${NC}" + else + echo -e "${RED}❌ Python binary not found in venv${NC}" + exit 1 + fi +else + echo -e "${YELLOW}⚠️ No virtual environment found at $VENV_PATH${NC}" + echo -e "${YELLOW} Using system Python...${NC}" + PYTHON_BIN="python3" +fi + +# Check if required packages are installed +echo -e "${CYAN}📦 Checking dependencies...${NC}" +if ! $PYTHON_BIN -c "import django" 2>/dev/null; then + echo -e "${RED}❌ Django not installed${NC}" + exit 1 +fi + +if [ "$SKIP_COVERAGE" = false ]; then + if ! $PYTHON_BIN -c "import coverage" 2>/dev/null; then + echo -e "${YELLOW}⚠️ Coverage not installed, installing...${NC}" + $PYTHON_BIN -m pip install coverage 2>&1 | grep -i "successfully installed" || true + fi +fi + +# Display test configuration +echo -e "\n${BOLD}Test Configuration:${NC}" +echo " • Verbosity: $VERBOSITY" +echo " • Fail Fast: $FAILFAST" +echo " • Keep DB: $KEEPDB" +echo " • Parallel: $PARALLEL processes" +echo " • Coverage: $([ "$SKIP_COVERAGE" = false ] && echo "Enabled" || echo "Disabled")" +echo " • HTML Report: $([ "$HTML_REPORT" = true ] && echo "Enabled" || echo "Disabled")" +if [ -n "$SPECIFIC_TEST" ]; then + echo " • Test Path: $SPECIFIC_TEST" +else + echo " • Test Path: All tests" +fi +echo "" + +# Build test command +TEST_CMD="$PYTHON_BIN manage.py test" + +if [ -n "$SPECIFIC_TEST" ]; then + TEST_CMD="$TEST_CMD $SPECIFIC_TEST" +fi + +TEST_CMD="$TEST_CMD --verbosity=$VERBOSITY" + +if [ "$FAILFAST" = true ]; then + TEST_CMD="$TEST_CMD --failfast" +fi + +if [ "$KEEPDB" = true ]; then + TEST_CMD="$TEST_CMD --keepdb" +fi + +if [ "$PARALLEL" -gt 1 ]; then + TEST_CMD="$TEST_CMD --parallel=$PARALLEL" +fi + +# Run tests with or without coverage +echo -e "${BLUE}${BOLD}Running Tests...${NC}" +echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}\n" + +if [ "$SKIP_COVERAGE" = false ]; then + # Run with coverage + $PYTHON_BIN -m coverage erase + $PYTHON_BIN -m coverage run --source='.' manage.py test \ + $([ -n "$SPECIFIC_TEST" ] && echo "$SPECIFIC_TEST") \ + --verbosity=$VERBOSITY \ + $([ "$FAILFAST" = true ] && echo "--failfast") \ + $([ "$KEEPDB" = true ] && echo "--keepdb") \ + $([ "$PARALLEL" -gt 1 ] && echo "--parallel=$PARALLEL") + TEST_EXIT_CODE=$? +else + # Run without coverage + eval $TEST_CMD + TEST_EXIT_CODE=$? +fi + +echo "" +echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}" + +# Display results +if [ $TEST_EXIT_CODE -eq 0 ]; then + echo -e "${GREEN}${BOLD}✅ All tests passed!${NC}\n" +else + echo -e "${RED}${BOLD}❌ Tests failed with exit code: $TEST_EXIT_CODE${NC}\n" +fi + +# Generate coverage report +if [ "$SKIP_COVERAGE" = false ] && [ $TEST_EXIT_CODE -eq 0 ]; then + echo -e "${BLUE}${BOLD}📊 Coverage Report${NC}" + echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}\n" + + # Terminal report + $PYTHON_BIN -m coverage report --skip-empty + + echo "" + + # HTML report if requested + if [ "$HTML_REPORT" = true ]; then + echo -e "${CYAN}📄 Generating HTML coverage report...${NC}" + $PYTHON_BIN -m coverage html + HTML_DIR="$BACKEND_DIR/htmlcov" + if [ -d "$HTML_DIR" ]; then + echo -e "${GREEN}✅ HTML report generated at: ${BOLD}$HTML_DIR/index.html${NC}" + echo -e "${CYAN} Open with: ${NC}firefox $HTML_DIR/index.html" + fi + echo "" + fi + + # Get coverage percentage + COVERAGE_PCT=$($PYTHON_BIN -m coverage report --skip-empty | grep "TOTAL" | awk '{print $4}') + if [ -n "$COVERAGE_PCT" ]; then + COVERAGE_NUM=$(echo "$COVERAGE_PCT" | tr -d '%') + echo -e "${BOLD}Overall Coverage: ${COVERAGE_PCT}${NC}" + + # Color-coded coverage assessment + if (( $(echo "$COVERAGE_NUM >= 80" | bc -l) )); then + echo -e "${GREEN}🎉 Excellent coverage!${NC}" + elif (( $(echo "$COVERAGE_NUM >= 60" | bc -l) )); then + echo -e "${YELLOW}⚠️ Good coverage, but could be improved${NC}" + else + echo -e "${RED}❌ Coverage needs improvement${NC}" + fi + fi +fi + +# Display test summary +echo "" +echo -e "${BLUE}${BOLD}📋 Test Suite Summary${NC}" +echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}" + +# Count test files +AUTOMATIONS_TESTS=$(find automations/tests -name "test_*.py" 2>/dev/null | wc -l) +USERS_TESTS=$(find users/tests -name "test_*.py" 2>/dev/null | wc -l) +TOTAL_TEST_FILES=$((AUTOMATIONS_TESTS + USERS_TESTS)) + +echo -e "${BOLD}Test Files:${NC}" +echo " • automations/tests: $AUTOMATIONS_TESTS files" +echo " • users/tests: $USERS_TESTS files" +echo " • Total: $TOTAL_TEST_FILES test files" +echo "" + +# List test modules +echo -e "${BOLD}Automations Test Modules:${NC}" +for test_file in $(find automations/tests -name "test_*.py" -type f | sort); do + test_name=$(basename "$test_file" .py) + echo " • $test_name" +done + +echo "" +echo -e "${BOLD}Users Test Modules:${NC}" +for test_file in $(find users/tests -name "test_*.py" -type f | sort); do + test_name=$(basename "$test_file" .py) + echo " • $test_name" +done + +echo "" +echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}" + +# Final status +if [ $TEST_EXIT_CODE -eq 0 ]; then + echo -e "${GREEN}${BOLD}✨ Test run completed successfully!${NC}" + exit 0 +else + echo -e "${RED}${BOLD}💥 Test run failed!${NC}" + echo -e "${YELLOW}Tip: Use -f (--failfast) to stop on first failure${NC}" + echo -e "${YELLOW}Tip: Use -v (--verbose) for more detailed output${NC}" + exit $TEST_EXIT_CODE +fi diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 133de4b0..8790eaec 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -112,7 +112,7 @@ services: dockerfile: Dockerfile.dev command: ["npm", "run", "dev", "--", "--host", "0.0.0.0"] ports: - - "${FRONTEND_DEV_PORT:-5173}:5173" # Vite dev server port + - "${FRONTEND_PORT:-5173}:5173" # Vite dev server port volumes: - ./frontend:/app:z # HOT RELOAD: Mount source code (z for SELinux) - /app/node_modules # Preserve node_modules from image diff --git a/frontend/src/components/NotionWebhookSection.tsx b/frontend/src/components/NotionWebhookSection.tsx index de903cd6..c6ed90d1 100644 --- a/frontend/src/components/NotionWebhookSection.tsx +++ b/frontend/src/components/NotionWebhookSection.tsx @@ -65,20 +65,68 @@ const NotionWebhookSection: React.FC = ({ isOAuthConn if (!isOAuthConnected) { return ( -
-

⚡ Real-time Webhooks

-

- Connect your Notion account via OAuth to enable webhook configuration. -

+
+
+ {/* Icon */} +
+ + + +
+ + {/* Content */} +
+
+

Real-time Webhooks

+ + Inactive + +
+
+

+ ⚠️ Connect your Notion account via OAuth to enable webhook configuration +

+
+
+
); } if (loading) { return ( -
-

⚡ Real-time Webhooks

-

Loading webhook status...

+
+
+
+ + + +
+
+

Real-time Webhooks

+

Loading webhook status...

+
+
); } @@ -91,128 +139,153 @@ const NotionWebhookSection: React.FC = ({ isOAuthConn webhookStatus; return ( -
-
-

⚡ Real-time Webhooks

- - {webhook_configured ? '✅ Enabled' : '⚠️ Polling Mode'} - -
- -
- {/* Status message */} -
-

{recommendation}

+
+
+ {/* Icon */} +
+ + +
- {/* Webhook URL */} - {!webhook_configured && ( + {/* Content */} +
+
+

Real-time Webhooks

+ + {webhook_configured ? 'Active' : 'Polling Mode'} + +
+
-
- -
- - -
+ {/* Status message */} +
+

{recommendation}

- {/* Configuration instructions */} -
-

📖 Setup Instructions:

-
    -
  1. - Go to your{' '} - - Notion Integrations page - -
  2. -
  3. Select your integration
  4. -
  5. Scroll to "Webhooks" section
  6. -
  7. Click "Add webhook endpoint" and paste the URL above
  8. -
  9. - Select API version:{' '} - 2022-06-28 -
  10. -
  11. Select events to listen to (see below)
  12. -
  13. Save the webhook and copy the secret key
  14. -
  15. Contact your admin to add the secret to server configuration
  16. -
-
+ {/* Webhook URL */} + {!webhook_configured && ( +
+
+ +
+ + +
+
+ + {/* Configuration instructions */} +
+

📖 Setup Instructions:

+
    +
  1. + Go to your{' '} + + Notion Integrations page + +
  2. +
  3. Select your integration
  4. +
  5. Scroll to "Webhooks" section
  6. +
  7. Click "Add webhook endpoint" and paste the URL above
  8. +
  9. + Select API version:{' '} + 2022-06-28 +
  10. +
  11. Select events to listen to (see below)
  12. +
  13. Save the webhook and copy the secret key
  14. +
  15. Contact your admin to add the secret to server configuration
  16. +
+
- {/* Supported events */} -
-

- 📡 Recommended Events to Enable: -

-
- {supported_events.map((event) => ( -
+

+ 📡 Recommended Events to Enable: +

+
+ {supported_events.map((event) => ( +
+ {event} +
+ ))} +
+
+
+ )} + + {/* Benefits section - only show when webhook is configured */} + {webhook_configured && ( +
+
+ - {event} + + +
+

Instant notifications

+

+ Your automations react immediately when events occur - no delays from polling +

- ))} +
+ )} + + {/* Current mode footer */} +
+ Current mode:{' '} + {polling_enabled ? '🔄 Polling every 5 minutes' : '⚡ Real-time webhooks'}
- )} - - {/* Benefits section */} -
-

- {webhook_configured ? '✨ Active Benefits:' : '💡 Webhook Benefits:'} -

-
    -
  • - - Instant notifications (no 5-minute polling delay) -
  • -
  • - - Reduced server load and API calls -
  • -
  • - - More reliable event detection -
  • -
  • - - Better user experience with real-time automations -
  • -
-
- - {/* Current mode */} -
- Current mode: {polling_enabled ? '🔄 Polling every 5 minutes' : '⚡ Real-time webhooks'}
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 2587839a..aae7022a 100755 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -103,23 +103,42 @@ const Dashboard: React.FC = () => { useEffect(() => { if (!services || services.length === 0) return; - const internalDefaults = new Set(['timer', 'debug', 'weather', 'webhook', 'email']); + const internalDefaults = new Set(['timer', 'debug', 'weather', 'email']); + + // Map service names to OAuth provider names + const serviceToProviderMap: Record = { + gmail: 'google', + googlecalendar: 'google', + youtube: 'google', + github: 'github', + slack: 'slack', + notion: 'notion', + spotify: 'spotify', + twitch: 'twitch', + }; - const connectedSet = new Set(); + const connectedProviders = new Set(); if (connectedServices && connectedServices.length > 0) { connectedServices.forEach((c) => { - if (!c.is_expired) connectedSet.add(normalizeServiceName(c.service_name)); + if (!c.is_expired) connectedProviders.add(normalizeServiceName(c.service_name)); }); } const active: string[] = []; services.forEach((s) => { - const key = normalizeServiceName(s.name); - if (internalDefaults.has(key)) { + const normalizedServiceName = normalizeServiceName(s.name); + + // Check if it's an internal service (always active) + if (internalDefaults.has(normalizedServiceName)) { active.push(s.name); return; } - if (connectedSet.has(key)) { + + // Get the OAuth provider for this service + const oauthProvider = serviceToProviderMap[normalizedServiceName] || normalizedServiceName; + + // Check if user is connected to the provider + if (connectedProviders.has(oauthProvider)) { active.push(s.name); } }); @@ -334,12 +353,7 @@ const Dashboard: React.FC = () => { }) ); setServices(formattedServices); - const generateRandomActiveServices = () => { - const randomServices = formattedServices - .sort(() => 0.5 - Math.random()) - .slice(0, Math.floor(Math.random() * 3) + 2); - return randomServices.map((s: Service) => s.name); - }; + const isFullyAuthenticated = () => { return ( storedUser && @@ -350,7 +364,8 @@ const Dashboard: React.FC = () => { }; if (isFullyAuthenticated()) { try { - setActiveServices(generateRandomActiveServices()); + // Note: activeServices is now managed by the useEffect that watches connectedServices + // This ensures the Service Usage chart always reflects real OAuth connections const fetchWithTimeout = async (url: string, options: RequestInit, timeout: number) => { const controller = new AbortController(); const { signal } = controller; @@ -386,43 +401,15 @@ const Dashboard: React.FC = () => { if (areasResponse && areasResponse.ok) { const areasData = await areasResponse.json(); setUserAreas(areasData); - const activeServiceNames = new Set(); - areasData.forEach( - (area: { - status?: string; - action?: { service?: string }; - reaction?: { service?: string }; - }) => { - if (area.status === 'active' && area.action && area.action.service) { - activeServiceNames.add(area.action.service); - } - if (area.status === 'active' && area.reaction && area.reaction.service) { - activeServiceNames.add(area.reaction.service); - } - } - ); - if (activeServiceNames.size > 0) { - setActiveServices(Array.from(activeServiceNames)); - } + // Note: We no longer update activeServices here + // It's automatically calculated by the useEffect watching connectedServices } } catch { - setActiveServices(generateRandomActiveServices()); + // Error fetching areas - activeServices is still managed by connectedServices useEffect } - } else { - const internalDefaults = new Set(['timer', 'debug', 'weather', 'webhook', 'email']); - const internalActive = formattedServices - .map((s: Service) => s.name) - .filter((n: string) => - internalDefaults.has( - (n || '') - .toString() - .toLowerCase() - .replace(/[^a-z0-9]/g, '') - ) - ); - if (internalActive.length > 0) setActiveServices(internalActive); - else setActiveServices(generateRandomActiveServices()); } + // Note: For non-authenticated users, activeServices will be empty or contain only internal services + // This is handled by the useEffect that watches connectedServices setError(null); } catch (err: unknown) { console.error('Failed to fetch services:', err); @@ -596,7 +583,40 @@ const Dashboard: React.FC = () => { ) : (
{sortedServices.map((service) => { - const isActive = activeServices.includes(service.name); + // Check if user is connected to this service + const normalizedServiceName = normalizeServiceName(service.name); + + // Internal services that don't require OAuth (always active) + const internalServices = new Set(['timer', 'debug', 'weather', 'email']); + const isInternalService = internalServices.has(normalizedServiceName); + + // Map service names to OAuth provider names + // Gmail, Calendar, YouTube all use "google" OAuth + const serviceToProviderMap: Record = { + gmail: 'google', + googlecalendar: 'google', // normalized from google_calendar + youtube: 'google', + github: 'github', + slack: 'slack', + notion: 'notion', + spotify: 'spotify', + twitch: 'twitch', + }; + + // Get the OAuth provider name for this service + const oauthProvider = + serviceToProviderMap[normalizedServiceName] || normalizedServiceName; + + // Check if service is in connectedServices + const isConnected = + connectedServices?.some( + (c) => + normalizeServiceName(c.service_name) === oauthProvider && !c.is_expired + ) ?? false; + + // Service is active if it's internal OR user is connected to it + const isActive = isInternalService || isConnected; + const logo = getServiceLogo(service); return (
{ alt={`${service.name} logo`} className="w-12 h-12 object-contain" style={ - ['timer', 'debug', 'email', 'webhook', 'weather'].includes( + ['timer', 'debug', 'email', 'weather'].includes( service.name.toLowerCase() ) ? { diff --git a/frontend/src/pages/Services.tsx b/frontend/src/pages/Services.tsx index fb295044..cb420a97 100755 --- a/frontend/src/pages/Services.tsx +++ b/frontend/src/pages/Services.tsx @@ -19,7 +19,7 @@ const Services: React.FC = () => { useAuthCheck(); const isInternalService = (serviceName: string) => { - return ['timer', 'debug', 'email', 'webhook', 'weather'].includes(serviceName.toLowerCase()); + return ['timer', 'debug', 'email', 'weather'].includes(serviceName.toLowerCase()); }; const wheelRef = useRef(null);