29 Commits
Author SHA1 Message Date
Keith Smith 289b5f3856 Document self-registration toggle 2026-04-15 17:47:49 -06:00
Keith Smith 53180a9469 Disable self-registration by default 2026-04-15 17:42:40 -06:00
Keith Smith 586e98fd95 Ignore local tooling files 2026-04-15 17:36:36 -06:00
Keith SmithandClaude Sonnet 4.5 d7d3e8d072 Add comprehensive API documentation
Complete REST API documentation including authentication, rate limiting, all endpoints (users, tasks, tags, time tracking, sharing, sync, notifications), error handling, and usage examples. Intended for developers building applications that integrate with KeepItGoing via the API.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 17:22:32 -07:00
Keith SmithandClaude Sonnet 4.5 c8ffa615f0 Fix recurring task duplication bug
Prevent process_recurring_tasks from creating duplicate tasks for dates that already have completed instances. Previously only checked for pending tasks, causing old completed recurring tasks to reappear as pending when subsequent instances went overdue.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 16:52:06 -07:00
Keith SmithandClaude Sonnet 4.5 cdcae378b7 Add CLAUDE.md documentation for Claude Code
Provides comprehensive guidance for future Claude Code instances including development commands, architecture overview, and common patterns.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 16:52:04 -07:00
Keith SmithandClaude Sonnet 4.5 7b4d024334 Add SITE_DOMAIN environment variable to all services
Fixes email notification links showing localhost:8000 instead of the
actual domain. The SITE_DOMAIN variable is now passed to web,
celery-worker, and celery-beat containers so emails include the
correct URL.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-10 09:15:33 -07:00
Keith SmithandClaude Sonnet 4.5 aca8abca56 Fix daily email timing to work across all timezones
Critical fix: Task now runs every hour instead of once at 6 AM UTC.
This ensures users in all timezones receive their email at 6 AM local time.

Changes:
- Run task every hour instead of once daily
- Check if it's 6-7 AM in user's timezone (1 hour window)
- Track sent emails in Notification model to prevent duplicates
- Add 'daily_email' notification type

Without this fix, users in timezones where 6 AM UTC is not morning
would never receive their daily email.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 09:34:39 -07:00
Keith SmithandClaude Sonnet 4.5 03cfdea42d Simplify notification system to daily email only
Replace complex notification system with a simple daily email:
- Send ONE email per day between 6-9 AM in user's timezone
- Show tasks due today and overdue tasks
- Only send if user has email_notifications enabled
- Remove all push notification logic
- Keep recurring task processor (runs daily at midnight)

This makes notifications much simpler and less intrusive while
still providing value to users.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 09:30:46 -07:00
Keith SmithandClaude Sonnet 4.5 842b958b43 Configure docker-compose to build from git repository
Update build contexts to pull from git repository URL instead of
local directory. This allows Portainer to build images directly
from the repository without requiring SSH access to the server.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 09:19:45 -07:00
Keith SmithandClaude Sonnet 4.5 ff534301f8 Use ps instead of pgrep for celery healthchecks
Change healthchecks to use 'ps aux | grep' instead of 'pgrep' since
procps may not be available in existing images. This works with the
base Python image without requiring a rebuild.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 09:10:02 -07:00
Keith SmithandClaude Sonnet 4.5 991d4f1eaa Add stack.env.example for Portainer git deployments
This allows deploying from git repository in Portainer by providing
an example environment variables file. Users can copy this and fill
in their actual values in Portainer's environment variables section.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 09:06:40 -07:00
Keith SmithandClaude Sonnet 4.5 6e18baf502 Fix celery-worker healthcheck command
Replace celery inspect ping with pgrep check for reliability.
The inspect command was failing in healthcheck context while
the worker process itself was running fine.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 08:52:42 -07:00
Keith SmithandClaude Sonnet 4.5 e0ee377a20 Fix celery container health issues
- Fix import error in notifications/tasks.py (timezone.datetime -> datetime)
- Add healthchecks to celery-worker and celery-beat containers
- Add procps package to Dockerfile for pgrep command
- Add email environment variables to celery containers

The import error was causing celery workers to crash when loading tasks.
Missing healthchecks prevented proper container health monitoring.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 08:47:58 -07:00
Keith SmithandClaude Sonnet 4.5 9dd5d9c154 Replace pytz with zoneinfo for timezone handling
Fixes ModuleNotFoundError by using Python's built-in zoneinfo module
instead of the external pytz dependency. zoneinfo is the standard
library solution for timezone handling in Python 3.9+.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 08:32:38 -07:00
Keith SmithandClaude Sonnet 4.5 8d5faa8a6e Add error handling for timezone conversion
Fixes 500 error when user timezone is invalid or missing.
Now gracefully falls back to UTC if timezone conversion fails.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 08:30:52 -07:00
Keith SmithandClaude Sonnet 4.5 f2ca07d05d Fix overdue calculation to use user's timezone
Tasks were incorrectly showing as overdue when they were due today.
The issue was that the overdue check was comparing the due date
against UTC's "today" instead of the user's local "today".

Now uses the user's timezone setting to determine the current date
when checking if a task is overdue. This ensures tasks due "today"
only become overdue when the user's local date advances to tomorrow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 08:28:59 -07:00
Keith SmithandClaude Sonnet 4.5 c994458e24 Fix priority sorting to use correct order
Previously priority sorting was alphabetical (high < low < medium < urgent)
instead of by importance. Now uses Django Case/When to map priority strings
to numeric values: urgent=4, high=3, medium=2, low=1.

Fixes both queryset sorting and list sorting for overdue filter.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 08:23:07 -07:00
Keith SmithandClaude Sonnet 4.5 5985fd010e Fix Chromium scrollbar styling to match dark theme
Add custom scrollbar styles for Chromium/Webkit browsers to match the
dark theme aesthetic. Firefox already had proper scrollbar styling, but
Chrome was showing default light gray scrollbars.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 08:18:13 -07:00
Keith SmithandClaude Sonnet 4.5 1d6b07bfed Implement recurring tasks functionality
Adds automatic creation of next task instance when recurring tasks are completed.

- Add calculate_next_due_date() and create_next_recurrence() methods to Task model
- Update task completion handlers in views and sync API to create next recurrence
- Add hourly Celery task to process any missed recurring tasks
- Support daily, weekly, biweekly, monthly, yearly recurrence patterns
- Respect recurrence_end_date limits

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-04 10:00:34 -07:00
Keith Smith c51fd846a2 Fix mobile sidebar background - use --surface like desktop 2025-12-27 08:58:36 -07:00
Keith Smith fcb5ab5ca3 Make mobile overlay transparent - sidebar slides over content without darkening 2025-12-27 08:56:57 -07:00
Keith Smith fc1a4844a3 Remove mobile sidebar overlay transparency - make fully opaque 2025-12-27 08:55:37 -07:00
Keith SmithandClaude Sonnet 4.5 7f2096db86 Increase mobile sidebar overlay opacity
Changed mobile overlay from 50% to 75% opacity (25% transparent)
to make the sidebar content more visible when open on mobile devices.

Updated rgba(0, 0, 0, 0.5) to rgba(0, 0, 0, 0.75)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-27 08:53:18 -07:00
Keith Smith ea40beb408 Update debug endpoint to show User-Agent detection in HTML 2025-12-26 22:31:25 -07:00
Keith SmithandClaude Sonnet 4.5 123d6af430 Update middleware to ADD security headers for browsers
Middleware now:
- Detects mobile app vs browser via User-Agent
- Mobile app: Removes frame-blocking headers (allows iframe)
- Browsers: Adds CSP and X-Frame-Options headers (security)

This ensures:
✓ Browsers get full CSP protection
✓ Mobile app can embed content
✓ No need for django-csp package
✓ All security managed in one place

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-26 22:29:00 -07:00
Keith SmithandClaude Sonnet 4.5 c686021f96 Fix middleware to also remove CSP frame-ancestors header
The CSP_FRAME_ANCESTORS = ("'none'",) setting in production.py was
blocking iframe embedding even after removing X-Frame-Options.

Updated middleware to:
- Detect Android WebView via 'wv' in User-Agent (more reliable)
- Remove both X-Frame-Options AND Content-Security-Policy headers
- This allows mobile app iframe embedding while keeping browser protection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-26 22:18:03 -07:00
Keith Smith 41abdf4074 Add debug endpoint to check mobile app User-Agent 2025-12-26 22:13:07 -07:00
Keith SmithandClaude Sonnet 4.5 31a0c3c8c5 Add middleware to allow mobile app iframe embedding
Created Django middleware that detects requests from the KeepItGoing
mobile app (via User-Agent) and removes X-Frame-Options header to
allow iframe embedding.

Changes:
- Created tasks/middleware/mobile_app.py with AllowMobileAppFramingMiddleware
- Added middleware to settings after XFrameOptionsMiddleware
- Detects Capacitor WebView User-Agent patterns
- Removes X-Frame-Options only for mobile app, keeps protection for browsers

This allows the mobile app to embed the website in an iframe while
maintaining clickjacking protection for regular web browsers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-26 22:08:05 -07:00
21 changed files with 2291 additions and 243 deletions
+5
View File
@@ -24,6 +24,7 @@ stack.env
# IDE # IDE
.idea/ .idea/
.vscode/ .vscode/
.codex
*.swp *.swp
*.swo *.swo
@@ -41,3 +42,7 @@ htmlcov/
# Firebase credentials # Firebase credentials
*firebase*.json *firebase*.json
# Local Node artifacts used for tooling in this repo
package.json
package-lock.json
+1170
View File
File diff suppressed because it is too large Load Diff
+446
View File
@@ -0,0 +1,446 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
### Local Development (Non-Docker)
```bash
# Set environment for development
export DJANGO_SETTINGS_MODULE=config.settings.development
# Create and activate virtual environment
python3 -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
# Database operations
python manage.py migrate
python manage.py makemigrations
python manage.py createsuperuser
# Run development server
python manage.py runserver
# Run tests
python manage.py test
# Django shell
python manage.py shell
# Collect static files (for production)
python manage.py collectstatic --noinput
```
### Docker Development
```bash
# Development with live reload
make dev
make dev-build # Rebuild containers
# Production mode
make prod
make build # Build production containers
make deploy # Full deployment (pull, build, migrate, restart)
# Database operations
make migrate
make makemigrations
make createsuperuser
make dbshell # Access database shell
make backup # Backup database
# Monitoring
make logs # All container logs
make logs-web # Web container logs only
make logs-celery # Celery worker logs
make status # Container status
# Control
make stop
make restart
make clean # Stop and remove all containers/volumes
# Shell access
make shell # Django shell
make test # Run tests in container
```
### Celery (Background Tasks)
```bash
# Local development (non-Docker)
# Terminal 1: Run Celery worker
celery -A config worker -l info
# Terminal 2: Run Celery beat (scheduled tasks)
celery -A config beat -l info
# In Docker, Celery runs automatically as separate services
```
## High-Level Architecture
### Django Apps
**users/** - User authentication and management
- Custom User model with email-based authentication (no username)
- Three-tier verification: email verification → admin approval → login
- JWT token authentication with refresh tokens
- Device token management for push notifications
- Rate-limited registration (5/hour) and login (10/hour)
**tasks/** - Core task management
- Hierarchical tasks with parent/subtask relationships
- Task status (pending, in_progress, completed, cancelled) and priority (low, medium, high, urgent)
- Recurrence patterns (daily, weekly, monthly, yearly, custom RRULE)
- Time tracking with start/stop timer functionality
- Tag system with colors and icons for organization
- Task sharing with permission levels (viewer/editor)
- Soft-delete architecture (is_deleted flag) for sync compatibility
**sync/** - Mobile app synchronization
- Offline-first bidirectional sync protocol
- Timestamp-based conflict detection with three resolution strategies (local, server, merged)
- Multi-device support with independent sync tokens per device
- Incremental sync (delta updates) and full sync support
- Uses sync_id (UUID) separate from Django id for mobile compatibility
- Soft-delete propagation to enable client-side deletion
**notifications/** - Email notifications and reminders
- Daily email digest (overdue + due today tasks)
- Timezone-aware scheduling (6-7 AM in user's local time)
- Deduplication (one email per user per day)
- Celery-based background processing
- Recurring task safety net (creates missed recurrences)
### Key Architectural Patterns
**UUID Primary Keys** - All models use UUID as primary key for distributed system compatibility and security.
**Dual ID System** - Each entity has both `id` (Django PK) and `sync_id` (for mobile sync). Sync protocol uses `sync_id` to maintain consistency across devices.
**Soft Delete Pattern** - All entities have `is_deleted` boolean instead of hard deletes. Dual managers enable filtering:
- `objects` - Default manager (excludes deleted)
- `all_objects` - Includes deleted items (used in sync)
**Multiple Serializers Per Context** - Different serializers for list/detail/sync endpoints to optimize performance:
- `TaskListSerializer` - Lightweight for list views
- `TaskSerializer` - Full details with nested subtasks
- `TaskSyncSerializer` - Uses sync_id, includes is_deleted
**Permission System** - Custom `IsOwnerOrReadOnlyIfShared` permission:
- Read access: Owner OR users with shared access
- Write access: Owner only
- Security filtering in serializers prevents tag/task leakage
**Custom Authentication Backend** - `EmailVerifiedApprovedBackend` enforces:
1. Email verification check
2. Admin approval check
3. Superusers bypass both checks
4. Stores error state in session for web UI
**Timezone-Aware Operations** - All time calculations use user's timezone:
- Daily emails scheduled in user's local 6-7 AM
- Overdue calculation relative to user's local date
- UTC storage, converted for display and logic
**Mobile App Support** - `AllowMobileAppFramingMiddleware` detects mobile app via User-Agent and removes X-Frame-Options to allow WebView embedding while maintaining security for web browsers.
### Sync Protocol Details
The sync endpoint (`POST /api/sync/`) handles offline-first synchronization:
**Request Format:**
```json
{
"device_id": "unique-device-id",
"last_sync_token": "previous-token-or-null",
"changes": {
"tasks": [...],
"tags": [...],
"time_entries": [...]
}
}
```
**Response Format:**
```json
{
"sync_token": "new-sync-token",
"server_time": "2025-01-10T12:00:00Z",
"server_changes": {
"tasks": [...],
"tags": [...],
"time_entries": [...]
},
"conflicts": [...]
}
```
**Conflict Detection:** Compares `updated_at` timestamp between client's last sync and server's current state. If server modified entity since last sync, conflict is flagged.
**Conflict Resolution Strategies:**
- **local** - Apply client changes (overwrite server)
- **server** - Keep server version (discard client)
- **merged** - Accept manually merged data
### Celery Task Schedule
Configured in `config/celery.py`:
**send_daily_task_email** - Runs every hour
- Fetches users with email notifications enabled
- Converts UTC to user's timezone
- Sends email only if user's local time is 6-7 AM
- Checks for existing notification to prevent duplicates
- Groups tasks: overdue + due today (pending/in_progress only)
**process_recurring_tasks** - Runs daily at midnight
- Safety net for recurring task creation
- Processes completions from last 48 hours
- Creates next recurrence if missing
- Prevents duplicate recurrences
### Settings Modules
**config.settings.development** - Local development
- DEBUG=True
- SQLite database
- Console email backend
- Minimal security
**config.settings.production** - Production deployment
- DEBUG=False
- PostgreSQL required
- SMTP email backend
- Full security (HTTPS, CSP, HSTS)
- Gunicorn with multiple workers
**config.settings.selfhosted** - Docker/self-hosted
- Environment-based configuration
- PostgreSQL with health checks
- Redis for Celery
- Configurable workers and timeout
- WhiteNoise for static files
### Database Models Reference
**Task Model Fields:**
- Hierarchy: `parent` (ForeignKey to self)
- Status: pending, in_progress, completed, cancelled
- Priority: low, medium, high, urgent
- Recurrence: `recurrence_type`, `recurrence_interval`, `recurrence_rule` (RRULE)
- Timing: `due_date`, `due_time`, `completed_at`, `created_at`, `updated_at`
- Soft delete: `is_deleted` (filters via `SoftDeleteManager`)
- Sync: `sync_id` (UUID for mobile sync)
- Relationships: `tags` (ManyToMany), `time_entries` (reverse FK)
**User Model Fields:**
- Authentication: `email` (unique), `password`, `first_name`, `last_name`
- Verification: `email_verified`, `is_approved`, `approved_by`
- Preferences: `timezone`, `reminder_minutes_before`, `notifications_enabled`, `email_notifications_enabled`
- UUID primary key for security
**TimeEntry Model:**
- `start_time`, `end_time`, `duration` (auto-calculated)
- Foreign keys: `task`, `user`
- Soft delete: `is_deleted`
**TaskShare Model:**
- `shared_by`, `shared_with` (User FKs)
- `permission_level`: viewer, editor
- Can share tasks or tags
- Generic relation pattern
### API Endpoint Overview
**Authentication:**
- `POST /api/users/register/` - Create account (rate limited 5/hour)
- `POST /api/users/token/` - Login (rate limited 10/hour)
- `POST /api/users/token/refresh/` - Refresh JWT
- `POST /api/users/verify-email/` - Verify email token
- `POST /api/users/resend-verification/` - Resend verification email
**Tasks:**
- `GET/POST /api/tasks/` - List/create tasks (supports filtering by status, priority, tags, search)
- `GET/PUT/DELETE /api/tasks/<id>/` - Task detail operations
- `POST /api/tasks/<id>/start-timer/` - Start time tracking
- `POST /api/tasks/time-entries/<id>/stop/` - Stop timer
- `GET/POST /api/tasks/tags/` - Tag management
- `GET/POST /api/tasks/shares/` - Share management
**Sync:**
- `POST /api/sync/` - Main sync endpoint (rate limited 100/hour)
- `GET /api/sync/conflicts/` - List pending conflicts
- `POST /api/sync/conflicts/<id>/resolve/` - Resolve conflict
**Notifications:**
- `GET /api/notifications/` - List notifications
- `POST /api/notifications/<id>/read/` - Mark as read
- `POST /api/notifications/mark-all-read/` - Mark all read
- `GET /api/notifications/unread-count/` - Get unread count
### Environment Variables
Key variables (see `.env.example` and `stack.env.example`):
**Django Core:**
- `SECRET_KEY` - Django secret (generate with `python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"`)
- `DEBUG` - Debug mode (False in production)
- `ALLOWED_HOSTS` - Comma-separated hosts
- `CSRF_TRUSTED_ORIGINS` - HTTPS origins
- `SITE_DOMAIN` - Domain for email links
**Database:**
- `DATABASE_URL` - Connection string (e.g., `postgresql://user:pass@host:5432/db`)
**Redis & Celery:**
- `REDIS_URL` - Redis connection (e.g., `redis://localhost:6379/0`)
**Email:**
- `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_USE_TLS`
- `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`
- `DEFAULT_FROM_EMAIL` - Sender address
- `EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS` - Token validity (default: 24)
**Docker/Gunicorn:**
- `GUNICORN_WORKERS` - Worker processes (default: 3)
- `GUNICORN_TIMEOUT` - Request timeout (default: 60)
- `WEB_PORT` - External port (default: 8000)
### Testing
Tests located in each app's `tests.py`. Run with:
```bash
python manage.py test # All tests
python manage.py test users # Specific app
python manage.py test users.tests.TestUserModel # Specific test
```
Docker:
```bash
make test # Run tests in container
```
### Deployment Notes
**Docker Deployment:**
- Production uses PostgreSQL (postgres:16-alpine)
- Redis for Celery message broker (redis:7-alpine)
- Web service runs Gunicorn with configurable workers
- Celery worker and beat run as separate services
- Health checks on all services
- Named volumes for persistence (db_data, redis_data, static_files)
- Networks: frontend (web-facing), backend (internal services)
**Manual Deployment:**
- Use `config.settings.production` settings module
- Requires PostgreSQL (not SQLite)
- Configure SMTP for email
- Use Gunicorn behind Nginx reverse proxy
- Set up systemd services for Gunicorn, Celery worker, Celery beat
- Configure SSL/TLS certificates (Let's Encrypt recommended)
- Enable firewall (UFW) with SSH, HTTP, HTTPS only
**Production Checklist:**
- Set DEBUG=False
- Use strong SECRET_KEY
- Configure ALLOWED_HOSTS (no wildcards)
- Set CSRF_TRUSTED_ORIGINS for HTTPS
- Use PostgreSQL (not SQLite)
- Configure email backend (SMTP)
- Enable security headers (in settings)
- Set up SSL/TLS certificates
- Configure database backups
- Set up log rotation
- Monitor error logs and uptime
### Common Development Patterns
**Creating Recurring Tasks:** When a recurring task is marked complete, the next recurrence is automatically created in `tasks/models.py` `create_next_recurrence()` method. Celery task `process_recurring_tasks()` runs as safety net.
**Time Entry Workflow:**
1. `POST /api/tasks/<id>/start-timer/` creates TimeEntry with start_time
2. Running timer tracked in UI
3. `POST /api/time-entries/<id>/stop/` sets end_time and calculates duration
**Email Verification Flow:**
1. User registers → `email_verified=False`, `is_approved=False`
2. EmailVerificationToken created with 24-hour expiry
3. User clicks link → `email_verified=True`, token marked used
4. Admins notified via email
5. Admin approves in Django admin → `is_approved=True`
6. User notified and can now login
**Adding New Celery Tasks:**
1. Create task function in app's `tasks.py` with `@shared_task` decorator
2. Add to beat schedule in `config/celery.py` if periodic
3. Import in app's `__init__.py` to ensure task registration
4. Restart Celery worker and beat services
**Extending User Model:** The User model is in `users/models.py`. Add fields there, create migration, update serializers and admin if needed.
**Security Filtering:** When adding new endpoints that query related objects, filter by user in viewset's `get_queryset()`. Example from TaskViewSet:
```python
def get_queryset(self):
return Task.objects.filter(user=self.request.user)
```
### File Structure Reference
```
config/ # Django project configuration
settings/ # Environment-specific settings
base.py # Shared settings
development.py # Local development
production.py # Production deployment
selfhosted.py # Docker/self-hosted
celery.py # Celery configuration
urls.py # Root URL configuration
wsgi.py # WSGI entry point
users/ # User management app
models.py # User, DeviceToken, EmailVerificationToken
views.py # Registration, login, profile
serializers.py # User serializers
backends.py # EmailVerifiedApprovedBackend
utils.py # Email utilities
tasks/ # Task management app
models.py # Task, Tag, TimeEntry, TaskShare
views.py # Task CRUD, filtering, timers
serializers.py # Multiple serializers per context
middleware.py # AllowMobileAppFramingMiddleware
permissions.py # IsOwnerOrReadOnlyIfShared
sync/ # Mobile sync app
models.py # SyncLog, SyncConflict
views.py # Sync endpoint, conflict resolution
serializers.py # SyncSerializer with sync_id
notifications/ # Notifications app
models.py # Notification, ScheduledReminder
views.py # Notification CRUD
tasks.py # Celery tasks (daily email, recurring tasks)
templates/ # Django templates
base.html # Base template with dark mode
tasks/ # Task-related templates
users/ # User-related templates
static/ # Static assets
css/ # Stylesheets
js/ # JavaScript
docker/ # Docker-related files
docker-compose.yml # Production Docker setup
docker-compose.dev.yml # Development overrides
Dockerfile # Production image
Dockerfile.dev # Development image
Makefile # Docker command shortcuts
```
+1
View File
@@ -28,6 +28,7 @@ FROM python:3.12-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
postgresql-client \ postgresql-client \
curl \ curl \
procps \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Create django user (non-root for security) # Create django user (non-root for security)
+18 -4
View File
@@ -25,8 +25,8 @@ A powerful Django-based task management system with time tracking, tag organizat
### User Management ### User Management
- **Multi-user Support**: Full authentication and user management - **Multi-user Support**: Full authentication and user management
- **Email Verification**: Secure email verification for new user registrations - **Email Verification**: Secure email verification for admin-created or self-registered accounts
- **Admin Approval**: Optional admin approval workflow for new users - **Admin Approval**: Optional approval workflow for new users
- **User Profiles**: Customizable profiles with timezone and notification preferences - **User Profiles**: Customizable profiles with timezone and notification preferences
- **Password Management**: Secure password change functionality - **Password Management**: Secure password change functionality
@@ -119,6 +119,15 @@ Set via environment variable:
export DJANGO_SETTINGS_MODULE=config.settings.development export DJANGO_SETTINGS_MODULE=config.settings.development
``` ```
### Registration Control
Self-registration is controlled with `ALLOW_SELF_REGISTRATION`.
- `ALLOW_SELF_REGISTRATION=False` disables public signup and requires an administrator to create accounts
- `ALLOW_SELF_REGISTRATION=True` enables the `/register/` page and `/api/users/register/` endpoint
If the variable is omitted, self-registration is disabled by default.
### Database Configuration ### Database Configuration
The application supports multiple database backends. Choose the one that fits your needs. The application supports multiple database backends. Choose the one that fits your needs.
@@ -955,11 +964,15 @@ sudo ufw status
# Log in to the web interface at https://tasks.firebugit.com/admin # Log in to the web interface at https://tasks.firebugit.com/admin
# Use the superuser credentials created in Step 3 # Use the superuser credentials created in Step 3
# When new users register: # If ALLOW_SELF_REGISTRATION=True and new users register:
# 1. They receive verification email # 1. They receive verification email
# 2. After clicking verification link, you receive admin notification # 2. After clicking verification link, you receive admin notification
# 3. Go to Admin > Users > select user(s) > Actions > "Approve selected users" # 3. Go to Admin > Users > select user(s) > Actions > "Approve selected users"
# 4. User receives approval email and can now log in # 4. User receives approval email and can now log in
#
# If ALLOW_SELF_REGISTRATION=False:
# 1. Create users through Django admin or management commands
# 2. Mark them verified/approved as needed for your onboarding flow
``` ```
### Production Checklist ### Production Checklist
@@ -994,7 +1007,8 @@ Complete this checklist before going live:
- [ ] Run `python manage.py migrate` to apply all migrations - [ ] Run `python manage.py migrate` to apply all migrations
- [ ] Run `python manage.py collectstatic` for static files - [ ] Run `python manage.py collectstatic` for static files
- [ ] Create superuser account for admin access - [ ] Create superuser account for admin access
- [ ] Test user registration and email verification flow - [ ] Confirm `ALLOW_SELF_REGISTRATION` is set correctly for production
- [ ] Test account onboarding flow (admin-created users or public registration)
- [ ] Test admin approval workflow - [ ] Test admin approval workflow
- [ ] Configure `SITE_DOMAIN` to your production domain - [ ] Configure `SITE_DOMAIN` to your production domain
- [ ] Set appropriate `EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS` - [ ] Set appropriate `EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS`
+6 -6
View File
@@ -22,13 +22,13 @@ app.autodiscover_tasks()
# Celery Beat schedule for periodic tasks # Celery Beat schedule for periodic tasks
app.conf.beat_schedule = { app.conf.beat_schedule = {
'send-due-reminders': { 'send-daily-task-email': {
'task': 'notifications.tasks.send_due_reminders', 'task': 'notifications.tasks.send_daily_task_email',
'schedule': crontab(minute='*/5'), # Every 5 minutes 'schedule': crontab(minute=0), # Every hour on the hour
}, },
'check-overdue-tasks': { 'process-recurring-tasks': {
'task': 'notifications.tasks.check_overdue_tasks', 'task': 'notifications.tasks.process_recurring_tasks',
'schedule': crontab(hour=8, minute=0), # Daily at 8 AM 'schedule': crontab(hour=0, minute=0), # Daily at midnight
}, },
} }
+2
View File
@@ -44,6 +44,7 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'tasks.middleware.AllowMobileAppFramingMiddleware', # Allow mobile app iframe embedding
] ]
ROOT_URLCONF = 'config.urls' ROOT_URLCONF = 'config.urls'
@@ -162,6 +163,7 @@ CELERY_RESULT_SERIALIZER = 'json'
SAAS_MODE = os.environ.get('SAAS_MODE', 'False').lower() == 'true' SAAS_MODE = os.environ.get('SAAS_MODE', 'False').lower() == 'true'
BILLING_ENABLED = SAAS_MODE BILLING_ENABLED = SAAS_MODE
USAGE_LIMITS_ENABLED = SAAS_MODE USAGE_LIMITS_ENABLED = SAAS_MODE
ALLOW_SELF_REGISTRATION = os.environ.get('ALLOW_SELF_REGISTRATION', 'False').lower() == 'true'
# Login settings # Login settings
LOGIN_URL = '/login/' LOGIN_URL = '/login/'
+4
View File
@@ -11,12 +11,16 @@ from users.urls import api_urlpatterns as users_api_urls, web_urlpatterns as use
from tasks.urls import api_urlpatterns as tasks_api_urls, web_urlpatterns as tasks_web_urls from tasks.urls import api_urlpatterns as tasks_api_urls, web_urlpatterns as tasks_web_urls
from sync.urls import api_urlpatterns as sync_api_urls from sync.urls import api_urlpatterns as sync_api_urls
from notifications.urls import api_urlpatterns as notifications_api_urls from notifications.urls import api_urlpatterns as notifications_api_urls
from tasks.views_debug import debug_user_agent
from .views import api_root from .views import api_root
urlpatterns = [ urlpatterns = [
# Admin # Admin
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
# Debug endpoint (remove in production)
path('debug/user-agent/', debug_user_agent, name='debug-user-agent'),
# API root # API root
path('api/', api_root, name='api-root'), path('api/', api_root, name='api-root'),
+30 -3
View File
@@ -46,7 +46,7 @@ services:
# ================================================================= # =================================================================
web: web:
build: build:
context: . context: https://git.firebugit.com/Firebug_IT/KeepItGoingServer.git#main
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: keepitgoing-web container_name: keepitgoing-web
restart: unless-stopped restart: unless-stopped
@@ -74,6 +74,7 @@ services:
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-} EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-True} EMAIL_USE_TLS: ${EMAIL_USE_TLS:-True}
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-noreply@localhost} DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-noreply@localhost}
SITE_DOMAIN: ${SITE_DOMAIN:-localhost:8000}
# Gunicorn # Gunicorn
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-3} GUNICORN_WORKERS: ${GUNICORN_WORKERS:-3}
@@ -103,7 +104,7 @@ services:
# ================================================================= # =================================================================
celery-worker: celery-worker:
build: build:
context: . context: https://git.firebugit.com/Firebug_IT/KeepItGoingServer.git#main
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: keepitgoing-celery-worker container_name: keepitgoing-celery-worker
restart: unless-stopped restart: unless-stopped
@@ -112,6 +113,13 @@ services:
SECRET_KEY: ${SECRET_KEY} SECRET_KEY: ${SECRET_KEY}
DATABASE_URL: postgresql://${DB_USER:-keepitgoing}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-keepitgoing} DATABASE_URL: postgresql://${DB_USER:-keepitgoing}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-keepitgoing}
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
EMAIL_HOST: ${EMAIL_HOST:-}
EMAIL_PORT: ${EMAIL_PORT:-587}
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-True}
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-noreply@localhost}
SITE_DOMAIN: ${SITE_DOMAIN:-localhost:8000}
networks: networks:
- backend - backend
depends_on: depends_on:
@@ -120,13 +128,19 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
command: celery-worker command: celery-worker
healthcheck:
test: ["CMD-SHELL", "ps aux | grep -v grep | grep 'celery.*worker' || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
# ================================================================= # =================================================================
# Celery Beat Service - Scheduled task scheduler # Celery Beat Service - Scheduled task scheduler
# ================================================================= # =================================================================
celery-beat: celery-beat:
build: build:
context: . context: https://git.firebugit.com/Firebug_IT/KeepItGoingServer.git#main
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: keepitgoing-celery-beat container_name: keepitgoing-celery-beat
restart: unless-stopped restart: unless-stopped
@@ -135,6 +149,13 @@ services:
SECRET_KEY: ${SECRET_KEY} SECRET_KEY: ${SECRET_KEY}
DATABASE_URL: postgresql://${DB_USER:-keepitgoing}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-keepitgoing} DATABASE_URL: postgresql://${DB_USER:-keepitgoing}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-keepitgoing}
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
EMAIL_HOST: ${EMAIL_HOST:-}
EMAIL_PORT: ${EMAIL_PORT:-587}
EMAIL_HOST_USER: ${EMAIL_HOST_USER:-}
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
EMAIL_USE_TLS: ${EMAIL_USE_TLS:-True}
DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-noreply@localhost}
SITE_DOMAIN: ${SITE_DOMAIN:-localhost:8000}
networks: networks:
- backend - backend
depends_on: depends_on:
@@ -143,6 +164,12 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
command: celery-beat command: celery-beat
healthcheck:
test: ["CMD-SHELL", "ps aux | grep -v grep | grep 'celery.*beat' || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# ================================================================= # =================================================================
# Named Volumes # Named Volumes
+1
View File
@@ -17,6 +17,7 @@ class Notification(models.Model):
('overdue', 'Overdue'), ('overdue', 'Overdue'),
('shared', 'Task Shared'), ('shared', 'Task Shared'),
('comment', 'Comment'), ('comment', 'Comment'),
('daily_email', 'Daily Email'),
] ]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+144 -200
View File
@@ -5,234 +5,178 @@ Celery tasks for notifications.
import logging import logging
from celery import shared_task from celery import shared_task
from django.utils import timezone from django.utils import timezone
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.conf import settings
from datetime import timedelta from datetime import timedelta
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@shared_task @shared_task
def send_due_reminders(): def send_daily_task_email():
""" """
Check for tasks due soon and send reminders. Send daily email to users with their tasks due today.
Runs every 5 minutes via Celery Beat. Runs once per day and sends to users in their local morning time.
"""
from tasks.models import Task
from users.models import DeviceToken
from .models import Notification, ScheduledReminder
now = timezone.now()
# Find scheduled reminders that need to be sent
reminders = ScheduledReminder.objects.filter(
remind_at__lte=now,
is_sent=False,
task__status__in=['pending', 'in_progress']
).select_related('task', 'task__user')
for reminder in reminders:
task = reminder.task
user = task.user
# Create notification
Notification.objects.create(
user=user,
notification_type='reminder',
title=f'Reminder: {task.title}',
message=f'Task "{task.title}" is due soon.',
task=task,
)
# Send push notifications
send_push_to_user.delay(
user_id=str(user.id),
title=f'Reminder: {task.title}',
body=f'Task is due soon.',
data={'task_id': str(task.id)}
)
# Mark as sent
reminder.is_sent = True
reminder.sent_at = now
reminder.save()
@shared_task
def check_overdue_tasks():
"""
Check for overdue tasks and notify users.
Runs daily.
"""
from tasks.models import Task
from .models import Notification
today = timezone.now().date()
# Find overdue tasks that haven't been notified today
overdue_tasks = Task.objects.filter(
due_date__lt=today,
status__in=['pending', 'in_progress']
).select_related('user')
for task in overdue_tasks:
# Check if we already sent an overdue notification today
existing = Notification.objects.filter(
user=task.user,
task=task,
notification_type='overdue',
created_at__date=today
).exists()
if not existing:
Notification.objects.create(
user=task.user,
notification_type='overdue',
title=f'Overdue: {task.title}',
message=f'Task "{task.title}" is overdue.',
task=task,
)
send_push_to_user.delay(
user_id=str(task.user.id),
title=f'Overdue: {task.title}',
body='This task is overdue.',
data={'task_id': str(task.id)}
)
@shared_task
def send_push_to_user(user_id, title, body, data=None):
"""
Send push notification to all user devices.
""" """
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from users.models import DeviceToken from tasks.models import Task
User = get_user_model() User = get_user_model()
try: # Get all users who have email notifications enabled
user = User.objects.get(id=user_id) users = User.objects.filter(
except User.DoesNotExist: email_notifications=True,
return email_verified=True,
is_active=True
if not user.push_notifications:
return
tokens = DeviceToken.objects.filter(user=user, is_active=True)
for token in tokens:
if token.platform == 'android':
send_fcm_notification.delay(token.token, title, body, data)
elif token.platform == 'web':
send_web_push_notification.delay(token.token, title, body, data)
elif token.platform == 'desktop':
# Desktop notifications are handled via WebSocket
pass
@shared_task
def send_fcm_notification(token, title, body, data=None):
"""Send FCM notification to Android device."""
from django.conf import settings
# Only send if FCM is configured
if not getattr(settings, 'FCM_CREDENTIALS_PATH', None):
return
try:
import firebase_admin
from firebase_admin import credentials, messaging
# Initialize Firebase if not already done
if not firebase_admin._apps:
cred = credentials.Certificate(settings.FCM_CREDENTIALS_PATH)
firebase_admin.initialize_app(cred)
message = messaging.Message(
notification=messaging.Notification(
title=title,
body=body,
),
data=data or {},
token=token,
) )
messaging.send(message) emails_sent = 0
current_utc_hour = timezone.now().hour
except Exception as e:
# Log error but don't fail
logger.error(f"FCM notification failed: {e}")
@shared_task
def send_web_push_notification(subscription_info, title, body, data=None):
"""Send Web Push notification."""
from django.conf import settings
vapid_private_key = getattr(settings, 'VAPID_PRIVATE_KEY', None)
vapid_email = getattr(settings, 'VAPID_ADMIN_EMAIL', None)
if not vapid_private_key or not vapid_email:
return
for user in users:
# Convert current UTC time to user's local time
try: try:
from pywebpush import webpush, WebPushException user_tz = ZoneInfo(user.timezone)
import json except Exception:
user_tz = ZoneInfo('UTC')
webpush( user_local_time = timezone.now().astimezone(user_tz)
subscription_info=json.loads(subscription_info), user_local_hour = user_local_time.hour
data=json.dumps({
'title': title, # Only send if it's between 6-7 AM in the user's timezone
'body': body, # (gives 1 hour window, task runs every hour)
'data': data or {} if not (6 <= user_local_hour < 7):
}), continue
vapid_private_key=vapid_private_key,
vapid_claims={'sub': f'mailto:{vapid_email}'} # Get today's date in user's timezone
today = user_local_time.date()
# Check if we already sent an email today
from .models import Notification
already_sent_today = Notification.objects.filter(
user=user,
notification_type='daily_email',
created_at__date=today
).exists()
if already_sent_today:
continue
# Get tasks due today
tasks_due_today = Task.objects.filter(
user=user,
due_date=today,
status__in=['pending', 'in_progress']
).order_by('due_time', 'priority', 'title')
# Get overdue tasks
overdue_tasks = Task.objects.filter(
user=user,
due_date__lt=today,
status__in=['pending', 'in_progress']
).order_by('due_date', 'due_time', 'priority', 'title')
# Only send email if there are tasks
if not tasks_due_today.exists() and not overdue_tasks.exists():
continue
# Prepare email content
subject = f"Your tasks for {today.strftime('%A, %B %d, %Y')}"
# Create plain text message
message_lines = [
f"Good morning! Here are your tasks for today:\n"
]
if overdue_tasks.exists():
message_lines.append(f"\n⚠️ OVERDUE TASKS ({overdue_tasks.count()}):")
for task in overdue_tasks[:10]: # Limit to 10
due_str = task.due_date.strftime('%b %d')
message_lines.append(f"{task.title} (due {due_str})")
if overdue_tasks.count() > 10:
message_lines.append(f" ... and {overdue_tasks.count() - 10} more")
if tasks_due_today.exists():
message_lines.append(f"\n📅 DUE TODAY ({tasks_due_today.count()}):")
for task in tasks_due_today:
time_str = task.due_time.strftime('%I:%M %p') if task.due_time else ''
priority_icon = {'high': '🔴', 'medium': '🟡', 'low': '🟢'}.get(task.priority, '')
message_lines.append(f"{priority_icon} {task.title} {time_str}".strip())
message_lines.append(f"\n\nView all tasks: https://{settings.SITE_DOMAIN}")
message_lines.append("\nYou can change your email preferences in your profile settings.")
message = '\n'.join(message_lines)
# Send email
try:
send_mail(
subject=subject,
message=message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[user.email],
fail_silently=False,
) )
# Record that we sent the email (prevents duplicates)
Notification.objects.create(
user=user,
notification_type='daily_email',
title=subject,
message=f"Daily email sent with {tasks_due_today.count()} tasks due today and {overdue_tasks.count()} overdue tasks"
)
emails_sent += 1
logger.info(f"Sent daily task email to {user.email}")
except Exception as e: except Exception as e:
logger.error(f"Web push notification failed: {e}") logger.error(f"Failed to send daily email to {user.email}: {e}")
logger.info(f"Daily task email job completed. Sent {emails_sent} emails.")
return emails_sent
@shared_task @shared_task
def schedule_task_reminder(task_id): def process_recurring_tasks():
""" """
Schedule a reminder for a task based on its due date and user preferences. Process completed recurring tasks and create next instances.
Called when a task is created or updated. This runs daily as a safety net to catch any tasks that weren't
automatically handled when marked as completed.
""" """
from tasks.models import Task from tasks.models import Task
from .models import ScheduledReminder
try: # Find completed recurring tasks from the last 48 hours
task = Task.objects.get(id=task_id) yesterday = timezone.now() - timedelta(days=2)
except Task.DoesNotExist:
return
# Remove existing scheduled reminders for this task completed_recurring_tasks = Task.objects.filter(
ScheduledReminder.objects.filter(task=task, is_sent=False).delete() status='completed',
recurrence__in=['daily', 'weekly', 'biweekly', 'monthly', 'yearly', 'custom'],
completed_at__gte=yesterday
).exclude(recurrence='none')
# Only schedule if task has a reminder time or due date tasks_created = 0
if task.reminder_at: for task in completed_recurring_tasks:
ScheduledReminder.objects.create( # Check if a next recurrence already exists
task=task, next_due_date = task.calculate_next_due_date()
remind_at=task.reminder_at if next_due_date:
) # Check if we already created this recurrence
elif task.due_date: # Check for any task (pending OR completed) to avoid duplicates
# Default reminder based on user preference existing = Task.objects.filter(
reminder_minutes = task.user.default_reminder_minutes user=task.user,
if task.due_time: title=task.title,
from datetime import datetime due_date=next_due_date,
due_datetime = datetime.combine(task.due_date, task.due_time) recurrence=task.recurrence
due_datetime = timezone.make_aware(due_datetime) ).exists()
else:
# Default to 9 AM on due date
due_datetime = timezone.make_aware(
timezone.datetime.combine(task.due_date, timezone.datetime.min.time())
).replace(hour=9)
remind_at = due_datetime - timedelta(minutes=reminder_minutes) if not existing:
# Create the next recurrence
new_task = task.create_next_recurrence()
if new_task:
tasks_created += 1
logger.info(f"Created recurring task: {new_task.title} (due: {new_task.due_date})")
if remind_at > timezone.now(): if tasks_created > 0:
ScheduledReminder.objects.create( logger.info(f"Created {tasks_created} recurring task instances")
task=task,
remind_at=remind_at return tasks_created
)
+142
View File
@@ -0,0 +1,142 @@
# ===================================================================
# KeepItGoing Server - Portainer Stack Environment Configuration
# ===================================================================
# This file is an EXAMPLE for Portainer deployments from git repository.
# When deploying in Portainer:
# 1. Copy the contents of this file
# 2. Paste into the "Environment variables" section in Portainer
# 3. Replace all placeholder values with your actual values
# DO NOT commit your actual stack.env file to version control!
# ===================================================================
# Django Core Settings
# ===================================================================
# SECURITY WARNING: Generate a strong secret key!
# Generate with: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
SECRET_KEY=CHANGE_ME_GENERATE_A_STRONG_SECRET_KEY_50_PLUS_CHARACTERS
# Debug mode (MUST be False in production)
DEBUG=False
# Comma-separated list of allowed hosts
# Example: api.yourdomain.com,localhost
ALLOWED_HOSTS=yourdomain.com,localhost
# Comma-separated list of trusted origins for CSRF (must include https://)
# Example: https://api.yourdomain.com,https://yourdomain.com
CSRF_TRUSTED_ORIGINS=https://yourdomain.com
# Domain used in email links
SITE_DOMAIN=yourdomain.com
# Public self-registration toggle
# Set to False to require admin-created accounts
ALLOW_SELF_REGISTRATION=False
# ===================================================================
# Database Configuration (PostgreSQL)
# ===================================================================
DB_NAME=keepitgoing
DB_USER=keepitgoing
DB_PASSWORD=CHANGE_ME_STRONG_DATABASE_PASSWORD
# ===================================================================
# Redis Configuration
# ===================================================================
REDIS_URL=redis://redis:6379/0
# ===================================================================
# Email Configuration
# ===================================================================
# Required for email verification, approval emails, and notifications
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
# Gmail Example (use App Password, not regular password):
# EMAIL_HOST=smtp.gmail.com
# EMAIL_PORT=587
# EMAIL_USE_TLS=True
# EMAIL_HOST_USER=your-email@gmail.com
# EMAIL_HOST_PASSWORD=your-app-password
# SendGrid Example:
# EMAIL_HOST=smtp.sendgrid.net
# EMAIL_PORT=587
# EMAIL_USE_TLS=True
# EMAIL_HOST_USER=apikey
# EMAIL_HOST_PASSWORD=your-sendgrid-api-key
# AWS SES Example:
# EMAIL_HOST=email-smtp.us-east-1.amazonaws.com
# EMAIL_PORT=587
# EMAIL_USE_TLS=True
# EMAIL_HOST_USER=your-aws-access-key
# EMAIL_HOST_PASSWORD=your-aws-secret-key
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_HOST_USER=your-email@gmail.com
EMAIL_HOST_PASSWORD=your-app-password
DEFAULT_FROM_EMAIL=KeepItGoing <noreply@yourdomain.com>
SERVER_EMAIL=server@yourdomain.com
# ===================================================================
# Email Verification Settings
# ===================================================================
EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS=24
# ===================================================================
# CORS Configuration
# ===================================================================
# Comma-separated list of allowed origins
# Example: https://yourdomain.com,https://app.yourdomain.com
CORS_ALLOWED_ORIGINS=https://yourdomain.com
# ===================================================================
# SaaS Mode
# ===================================================================
# Set to True for SaaS deployment with billing/usage limits
# Set to False for self-hosted deployment (no limits)
SAAS_MODE=False
# ===================================================================
# Web Server Configuration
# ===================================================================
# Port to expose the web service on (default: 8000)
# Change this if port 8000 is already in use
WEB_PORT=8000
# ===================================================================
# Gunicorn Configuration
# ===================================================================
GUNICORN_WORKERS=3
GUNICORN_TIMEOUT=60
# ===================================================================
# Push Notifications (Optional)
# ===================================================================
# Leave empty if not using push notifications
# Firebase Cloud Messaging (FCM)
FCM_CREDENTIALS_PATH=
# Web Push (VAPID keys)
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_ADMIN_EMAIL=
# ===================================================================
# Additional Settings
# ===================================================================
# Django settings module (usually don't need to change)
DJANGO_SETTINGS_MODULE=config.settings.selfhosted
+33 -3
View File
@@ -91,6 +91,36 @@
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3); --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
} }
/* ============================================
Scrollbar Styling
============================================ */
/* Firefox scrollbar styling */
* {
scrollbar-width: thin;
scrollbar-color: var(--border) var(--surface);
}
/* Chromium/Webkit scrollbar styling */
*::-webkit-scrollbar {
width: 12px;
height: 12px;
}
*::-webkit-scrollbar-track {
background: var(--surface);
}
*::-webkit-scrollbar-thumb {
background-color: var(--border);
border-radius: 6px;
border: 3px solid var(--surface);
}
*::-webkit-scrollbar-thumb:hover {
background-color: var(--surface-hover);
}
/* ============================================ /* ============================================
Reset & Base Reset & Base
============================================ */ ============================================ */
@@ -872,12 +902,12 @@ a:hover {
height: 24px; height: 24px;
} }
/* Mobile overlay */ /* Mobile overlay - removed dark background, sidebar slides over content */
.mobile-overlay { .mobile-overlay {
display: none; display: none;
position: fixed; position: fixed;
inset: 0; inset: 0;
background-color: rgba(0, 0, 0, 0.5); background-color: transparent;
z-index: 40; z-index: 40;
} }
@@ -958,7 +988,7 @@ a:hover {
z-index: 100; /* Higher z-index */ z-index: 100; /* Higher z-index */
transform: translateX(-100%); transform: translateX(-100%);
transition: transform 0.3s ease-in-out; transition: transform 0.3s ease-in-out;
background: var(--bg-primary); background-color: var(--surface);
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
overflow-y: auto; overflow-y: auto;
} }
+7
View File
@@ -291,6 +291,9 @@ def process_time_entry_changes(user, entry_changes, last_sync_at):
def update_task_from_data(task, data): def update_task_from_data(task, data):
"""Update a task from sync data.""" """Update a task from sync data."""
# Track old status to detect completion
old_status = task.status
task.title = data.get('title', task.title) task.title = data.get('title', task.title)
task.description = data.get('description', task.description) task.description = data.get('description', task.description)
task.status = data.get('status', task.status) task.status = data.get('status', task.status)
@@ -302,6 +305,10 @@ def update_task_from_data(task, data):
task.recurrence_rule = data.get('recurrence_rule', task.recurrence_rule) task.recurrence_rule = data.get('recurrence_rule', task.recurrence_rule)
task.sort_order = data.get('sort_order', task.sort_order) task.sort_order = data.get('sort_order', task.sort_order)
# Create next recurrence if task is being marked as completed
if old_status != 'completed' and task.status == 'completed' and task.recurrence != 'none':
task.create_next_recurrence()
task.save() task.save()
# Handle tags # Handle tags
+3
View File
@@ -0,0 +1,3 @@
from .mobile_app import AllowMobileAppFramingMiddleware
__all__ = ['AllowMobileAppFramingMiddleware']
+61
View File
@@ -0,0 +1,61 @@
"""
Middleware to allow iframe embedding for the KeepItGoing mobile app.
The mobile app uses Capacitor WebView which embeds the website in an iframe.
This middleware detects requests from the mobile app and removes both
X-Frame-Options and Content-Security-Policy frame-ancestors headers to allow
iframe embedding, while keeping clickjacking protection for regular web browsers.
"""
class AllowMobileAppFramingMiddleware:
"""
Remove frame-blocking headers for requests from KeepItGoing mobile app.
The mobile app uses a Capacitor WebView. We detect these requests via
User-Agent and remove X-Frame-Options and CSP frame-ancestors headers.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
# Check if request is from KeepItGoing mobile app
user_agent = request.META.get('HTTP_USER_AGENT', '')
# Detect Capacitor/Android WebView patterns
is_mobile_app = (
'wv' in user_agent.lower() or # Android WebView
'CapacitorHttp' in user_agent or
'com.firebugit.keepitgoing' in user_agent or
('KeepItGoing' in user_agent and 'Mobile' in user_agent)
)
if is_mobile_app:
# Mobile app: Allow iframe embedding - don't add frame-blocking headers
# Remove any existing frame headers
if 'X-Frame-Options' in response:
del response['X-Frame-Options']
if 'Content-Security-Policy' in response:
del response['Content-Security-Policy']
else:
# Regular browsers: Add security headers for clickjacking protection
if 'X-Frame-Options' not in response:
response['X-Frame-Options'] = 'DENY'
if 'Content-Security-Policy' not in response:
response['Content-Security-Policy'] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: https:; "
"font-src 'self' data:; "
"connect-src 'self'; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self';"
)
return response
+85 -1
View File
@@ -153,7 +153,18 @@ class Task(models.Model):
"""Check if task is overdue.""" """Check if task is overdue."""
if self.due_date and self.status not in ('completed', 'cancelled'): if self.due_date and self.status not in ('completed', 'cancelled'):
from django.utils import timezone from django.utils import timezone
return self.due_date < timezone.now().date() from zoneinfo import ZoneInfo
# Get current date in user's timezone
try:
user_tz = ZoneInfo(self.user.timezone)
user_now = timezone.now().astimezone(user_tz)
user_today = user_now.date()
except (Exception,):
# Fall back to UTC if user timezone is invalid or missing
user_today = timezone.now().date()
return self.due_date < user_today
return False return False
@property @property
@@ -174,6 +185,79 @@ class Task(models.Model):
secs = seconds % 60 secs = seconds % 60
return f"{hours}:{minutes:02d}:{secs:02d}" return f"{hours}:{minutes:02d}:{secs:02d}"
def calculate_next_due_date(self, from_date=None):
"""
Calculate the next due date based on recurrence pattern.
Returns None if task doesn't recur or has reached end date.
"""
if self.recurrence == 'none':
return None
from datetime import timedelta
from dateutil.relativedelta import relativedelta
base_date = from_date or self.due_date
if not base_date:
from django.utils import timezone
base_date = timezone.now().date()
# Calculate next date based on recurrence type
if self.recurrence == 'daily':
next_date = base_date + timedelta(days=1)
elif self.recurrence == 'weekly':
next_date = base_date + timedelta(weeks=1)
elif self.recurrence == 'biweekly':
next_date = base_date + timedelta(weeks=2)
elif self.recurrence == 'monthly':
next_date = base_date + relativedelta(months=1)
elif self.recurrence == 'yearly':
next_date = base_date + relativedelta(years=1)
elif self.recurrence == 'custom' and self.recurrence_rule:
# TODO: Implement RRULE parsing for custom recurrence
# For now, default to weekly
next_date = base_date + timedelta(weeks=1)
else:
return None
# Check if we've passed the end date
if self.recurrence_end_date and next_date > self.recurrence_end_date:
return None
return next_date
def create_next_recurrence(self):
"""
Create the next instance of this recurring task.
Returns the new task or None if no recurrence should be created.
"""
if self.recurrence == 'none':
return None
next_due_date = self.calculate_next_due_date()
if not next_due_date:
return None
# Create new task with same properties
new_task = Task.objects.create(
user=self.user,
parent=self.parent,
title=self.title,
description=self.description,
status='pending',
priority=self.priority,
due_date=next_due_date,
due_time=self.due_time,
recurrence=self.recurrence,
recurrence_rule=self.recurrence_rule,
recurrence_end_date=self.recurrence_end_date,
sort_order=self.sort_order,
)
# Copy tags
new_task.tags.set(self.tags.all())
return new_task
class TimeEntry(models.Model): class TimeEntry(models.Model):
""" """
+46 -8
View File
@@ -218,6 +218,14 @@ class DashboardView(View):
if not request.user.is_authenticated: if not request.user.is_authenticated:
return redirect('login') return redirect('login')
# Get today's date in user's timezone
from zoneinfo import ZoneInfo
try:
user_tz = ZoneInfo(request.user.timezone)
user_now = timezone.now().astimezone(user_tz)
today = user_now.date()
except (Exception,):
# Fall back to UTC if user timezone is invalid or missing
today = timezone.now().date() today = timezone.now().date()
# Get filter parameters # Get filter parameters
@@ -271,20 +279,37 @@ class DashboardView(View):
# Apply sorting before converting to list # Apply sorting before converting to list
if not isinstance(tasks, list): if not isinstance(tasks, list):
# Define priority order for sorting
from django.db.models import Case, When, IntegerField, F
priority_order_case = Case(
When(priority='urgent', then=4),
When(priority='high', then=3),
When(priority='medium', then=2),
When(priority='low', then=1),
default=0,
output_field=IntegerField(),
)
if current_sort == 'due_date': if current_sort == 'due_date':
# Sort by due date (nulls last), then priority # Sort by due date (nulls last), then priority
from django.db.models import F tasks = tasks.annotate(priority_order=priority_order_case).order_by(
tasks = tasks.order_by(F('due_date').asc(nulls_last=True), '-priority') F('due_date').asc(nulls_last=True), '-priority_order'
)
elif current_sort == 'due_date_desc': elif current_sort == 'due_date_desc':
# Sort by due date descending (nulls last), then priority # Sort by due date descending (nulls last), then priority
from django.db.models import F tasks = tasks.annotate(priority_order=priority_order_case).order_by(
tasks = tasks.order_by(F('due_date').desc(nulls_last=True), '-priority') F('due_date').desc(nulls_last=True), '-priority_order'
)
elif current_sort == 'priority': elif current_sort == 'priority':
# Sort by priority, then due date # Sort by priority (high to low), then due date
tasks = tasks.order_by('-priority', 'due_date') tasks = tasks.annotate(priority_order=priority_order_case).order_by(
'-priority_order', 'due_date'
)
elif current_sort == 'priority_low': elif current_sort == 'priority_low':
# Sort by priority (low to high), then due date # Sort by priority (low to high), then due date
tasks = tasks.order_by('priority', 'due_date') tasks = tasks.annotate(priority_order=priority_order_case).order_by(
'priority_order', 'due_date'
)
# else: default ordering from model (sort_order, -priority, due_date, created_at) # else: default ordering from model (sort_order, -priority, due_date, created_at)
# Convert to list if not already (for overdue filter) # Convert to list if not already (for overdue filter)
@@ -293,7 +318,7 @@ class DashboardView(View):
# Apply sorting to lists (for overdue filter case) # Apply sorting to lists (for overdue filter case)
else: else:
priority_order = {'high': 3, 'medium': 2, 'low': 1} priority_order = {'urgent': 4, 'high': 3, 'medium': 2, 'low': 1}
if current_sort == 'due_date': if current_sort == 'due_date':
tasks = sorted(tasks, key=lambda t: (t.due_date or timezone.now().date() + timezone.timedelta(days=9999), -priority_order.get(t.priority, 0))) tasks = sorted(tasks, key=lambda t: (t.due_date or timezone.now().date() + timezone.timedelta(days=9999), -priority_order.get(t.priority, 0)))
elif current_sort == 'due_date_desc': elif current_sort == 'due_date_desc':
@@ -425,6 +450,9 @@ class TaskDetailView(View):
task.title = request.POST.get('title', task.title) task.title = request.POST.get('title', task.title)
task.description = request.POST.get('description', '') task.description = request.POST.get('description', '')
# Track old status to detect completion
old_status = task.status
# Validate status against allowed choices # Validate status against allowed choices
status = request.POST.get('status', task.status) status = request.POST.get('status', task.status)
valid_statuses = [choice[0] for choice in Task.STATUS_CHOICES] valid_statuses = [choice[0] for choice in Task.STATUS_CHOICES]
@@ -451,6 +479,10 @@ class TaskDetailView(View):
else: else:
task.recurrence = 'none' task.recurrence = 'none'
# Create next recurrence if task is being marked as completed
if old_status != 'completed' and task.status == 'completed' and task.recurrence != 'none':
task.create_next_recurrence()
task.save() task.save()
# Handle tags # Handle tags
@@ -564,7 +596,13 @@ def task_toggle_status(request, task_id):
if task.status == 'completed': if task.status == 'completed':
task.status = 'pending' task.status = 'pending'
else: else:
# Mark as completed
task.status = 'completed' task.status = 'completed'
# Create next recurrence if this is a recurring task
if task.recurrence != 'none':
task.create_next_recurrence()
task.save() task.save()
next_url = request.POST.get('next', 'dashboard') next_url = request.POST.get('next', 'dashboard')
+40
View File
@@ -0,0 +1,40 @@
"""Debug views for troubleshooting mobile app."""
from django.http import JsonResponse, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
@csrf_exempt
@require_http_methods(["GET", "POST"])
def debug_user_agent(request):
"""Return the User-Agent header for debugging mobile app."""
user_agent = request.META.get('HTTP_USER_AGENT', 'No User-Agent')
# Simple HTML response that's easy to see in iframe
html = f"""
<!DOCTYPE html>
<html>
<head>
<title>Debug Info</title>
<style>
body {{ font-family: monospace; padding: 20px; }}
.detected {{ color: green; font-weight: bold; }}
.not-detected {{ color: red; font-weight: bold; }}
</style>
</head>
<body>
<h2>Mobile App Debug Info</h2>
<p><strong>User-Agent:</strong><br>{user_agent}</p>
<p><strong>Contains 'wv':</strong> {'wv' in user_agent.lower()}</p>
<p><strong>Contains 'CapacitorHttp':</strong> {'CapacitorHttp' in user_agent}</p>
<p><strong>Is Mobile App Detected:</strong>
<span class="{'detected' if ('wv' in user_agent.lower() or 'CapacitorHttp' in user_agent) else 'not-detected'}">
{('wv' in user_agent.lower() or 'CapacitorHttp' in user_agent)}
</span>
</p>
</body>
</html>
"""
return HttpResponse(html)
+2
View File
@@ -33,8 +33,10 @@
<button type="submit" class="btn btn-primary btn-full">Login</button> <button type="submit" class="btn btn-primary btn-full">Login</button>
</form> </form>
{% if allow_self_registration %}
<p class="auth-footer"> <p class="auth-footer">
Don't have an account? <a href="{% url 'register' %}">Register</a> Don't have an account? <a href="{% url 'register' %}">Register</a>
</p> </p>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}
+31 -4
View File
@@ -3,6 +3,7 @@ User views for KeepItGoing.
""" """
from django.contrib.auth import get_user_model, login, logout from django.contrib.auth import get_user_model, login, logout
from django.conf import settings
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
from django.utils import timezone from django.utils import timezone
from django.views import View from django.views import View
@@ -27,6 +28,13 @@ from .throttles import LoginRateThrottle, RegisterRateThrottle
User = get_user_model() User = get_user_model()
def self_registration_disabled_response():
"""Standard response payload for disabled self-registration."""
return {
'detail': 'Self-registration is disabled. Contact an administrator to create your account.'
}
# ============================================================================= # =============================================================================
# API Views # API Views
# ============================================================================= # =============================================================================
@@ -36,9 +44,7 @@ class UsersAPIRoot(APIView):
permission_classes = [permissions.AllowAny] permission_classes = [permissions.AllowAny]
def get(self, request): def get(self, request):
return Response({ endpoints = {
'endpoints': {
'register': '/api/users/register/',
'login': '/api/users/token/', 'login': '/api/users/token/',
'refresh_token': '/api/users/token/refresh/', 'refresh_token': '/api/users/token/refresh/',
'verify_email': '/api/users/verify-email/', 'verify_email': '/api/users/verify-email/',
@@ -47,7 +53,9 @@ class UsersAPIRoot(APIView):
'change_password': '/api/users/change-password/', 'change_password': '/api/users/change-password/',
'devices': '/api/users/devices/', 'devices': '/api/users/devices/',
} }
}) if settings.ALLOW_SELF_REGISTRATION:
endpoints['register'] = '/api/users/register/'
return Response({'endpoints': endpoints})
class RegisterAPIView(generics.CreateAPIView): class RegisterAPIView(generics.CreateAPIView):
@@ -59,6 +67,12 @@ class RegisterAPIView(generics.CreateAPIView):
throttle_classes = [RegisterRateThrottle] throttle_classes = [RegisterRateThrottle]
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
if not settings.ALLOW_SELF_REGISTRATION:
return Response(
self_registration_disabled_response(),
status=status.HTTP_403_FORBIDDEN
)
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
user = serializer.save() user = serializer.save()
@@ -284,6 +298,7 @@ class LoginView(View):
'error': error, 'error': error,
'email': email, 'email': email,
'can_resend': can_resend, 'can_resend': can_resend,
'allow_self_registration': settings.ALLOW_SELF_REGISTRATION,
}) })
def post(self, request): def post(self, request):
@@ -319,6 +334,7 @@ class LoginView(View):
'error': error, 'error': error,
'email': email, 'email': email,
'can_resend': can_resend, 'can_resend': can_resend,
'allow_self_registration': settings.ALLOW_SELF_REGISTRATION,
}) })
@@ -340,9 +356,20 @@ class RegisterView(View):
def get(self, request): def get(self, request):
if request.user.is_authenticated: if request.user.is_authenticated:
return redirect('dashboard') return redirect('dashboard')
if not settings.ALLOW_SELF_REGISTRATION:
return render(request, 'users/login.html', {
'error': 'Self-registration is disabled. Contact an administrator to create your account.',
'allow_self_registration': False,
}, status=403)
return render(request, 'users/register.html') return render(request, 'users/register.html')
def post(self, request): def post(self, request):
if not settings.ALLOW_SELF_REGISTRATION:
return render(request, 'users/login.html', {
'error': 'Self-registration is disabled. Contact an administrator to create your account.',
'allow_self_registration': False,
}, status=403)
email = request.POST.get('email') email = request.POST.get('email')
username = request.POST.get('username') username = request.POST.get('username')
password = request.POST.get('password') password = request.POST.get('password')