Internal
Public Access
Compare commits
23
Commits
v1.2.0
...
2026.8.31.1
@@ -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
|
||||||
|
|||||||
@@ -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, biweekly, monthly, yearly, or custom RRULE for day-of-week/nth-weekday/day-of-month control)
|
||||||
|
- 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` (none/daily/weekly/biweekly/monthly/yearly/custom), `recurrence_rule` (RRULE string, used when `recurrence='custom'`, parsed via `dateutil.rrule`), `recurrence_end_date`
|
||||||
|
- 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, which calls `calculate_next_due_date()`. For `recurrence='custom'`, the next date is computed by evaluating `recurrence_rule` (an RRULE string) via `dateutil.rrule`; the web UI's Custom recurrence builder (in `templates/tasks/_recurrence_fields.html` and `static/js/app.js`) generates these strings for day-of-week, nth-weekday, and day-of-month patterns. 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
|
||||||
|
```
|
||||||
@@ -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)
|
||||||
|
|||||||
+10
-11
@@ -8,7 +8,7 @@ This guide will walk you through deploying the KeepItGoing server to your Docker
|
|||||||
|
|
||||||
- Portainer installed and accessible
|
- Portainer installed and accessible
|
||||||
- Access to your Docker server via Portainer
|
- Access to your Docker server via Portainer
|
||||||
- Git repository: `https://git.firebugit.com/Firebug_IT/KeepItGoingServer.git`
|
- Git repository: `https://git.darksingularity.org/DarkSingularity/KeepItGoingServer.git`
|
||||||
- Git credentials configured in Portainer (or repository is public)
|
- Git credentials configured in Portainer (or repository is public)
|
||||||
|
|
||||||
## Deployment Steps
|
## Deployment Steps
|
||||||
@@ -31,7 +31,7 @@ This guide will walk you through deploying the KeepItGoing server to your Docker
|
|||||||
|
|
||||||
**Repository URL:**
|
**Repository URL:**
|
||||||
```
|
```
|
||||||
https://git.firebugit.com/Firebug_IT/KeepItGoingServer.git
|
https://git.darksingularity.org/DarkSingularity/KeepItGoingServer.git
|
||||||
```
|
```
|
||||||
|
|
||||||
**Repository reference:** `refs/heads/main`
|
**Repository reference:** `refs/heads/main`
|
||||||
@@ -40,17 +40,16 @@ This guide will walk you through deploying the KeepItGoing server to your Docker
|
|||||||
|
|
||||||
**Authentication:**
|
**Authentication:**
|
||||||
- If repository is private, enable authentication
|
- If repository is private, enable authentication
|
||||||
- Username: `keith@firebugit.com`
|
- Username: your Gitea account username
|
||||||
- Password: (your Git password)
|
- Password: a Gitea access token (recommended over your account password)
|
||||||
- Or use an access token if available
|
|
||||||
|
|
||||||
### Step 4: Configure Environment Variables
|
### Step 4: Configure Environment Variables
|
||||||
|
|
||||||
Scroll down to the **Environment variables** section and paste the following:
|
Scroll down to the **Environment variables** section and paste the following, replacing every `<...>` placeholder with the actual value from your password manager / secrets store (never commit real values to this file):
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# Django Core Settings
|
# Django Core Settings
|
||||||
SECRET_KEY=Zfb$!,3Gm\b,x~:a)9@k*Gun.#F{ju3dH-G8aWp4R"&NQ{1x>14v#)EbhpB*XMWD
|
SECRET_KEY=<django-secret-key>
|
||||||
DEBUG=False
|
DEBUG=False
|
||||||
ALLOWED_HOSTS=keepitgoing.app,www.keepitgoing.app,localhost
|
ALLOWED_HOSTS=keepitgoing.app,www.keepitgoing.app,localhost
|
||||||
CSRF_TRUSTED_ORIGINS=https://keepitgoing.app,https://www.keepitgoing.app
|
CSRF_TRUSTED_ORIGINS=https://keepitgoing.app,https://www.keepitgoing.app
|
||||||
@@ -59,8 +58,8 @@ SITE_DOMAIN=keepitgoing.app
|
|||||||
# Database Configuration
|
# Database Configuration
|
||||||
DB_NAME=keepitgoing
|
DB_NAME=keepitgoing
|
||||||
DB_USER=keepitgoing
|
DB_USER=keepitgoing
|
||||||
DB_PASSWORD=6SWh6Pz01hBFGNzWM0Pszz2moluqxyv7
|
DB_PASSWORD=<db-password>
|
||||||
DB_ROOT_PASSWORD=l7xF4j20HxqE2ZGNDR2FIoCCYwb6tDMq
|
DB_ROOT_PASSWORD=<db-root-password>
|
||||||
DB_HOST=mariadb
|
DB_HOST=mariadb
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
|
|
||||||
@@ -73,7 +72,7 @@ EMAIL_HOST=smtp.dreamhost.com
|
|||||||
EMAIL_PORT=587
|
EMAIL_PORT=587
|
||||||
EMAIL_USE_TLS=True
|
EMAIL_USE_TLS=True
|
||||||
EMAIL_HOST_USER=kig@keepitgoing.app
|
EMAIL_HOST_USER=kig@keepitgoing.app
|
||||||
EMAIL_HOST_PASSWORD=Frankenmonster1!
|
EMAIL_HOST_PASSWORD=<email-host-password>
|
||||||
DEFAULT_FROM_EMAIL=KeepItGoing <kig@keepitgoing.app>
|
DEFAULT_FROM_EMAIL=KeepItGoing <kig@keepitgoing.app>
|
||||||
SERVER_EMAIL=kig@keepitgoing.app
|
SERVER_EMAIL=kig@keepitgoing.app
|
||||||
|
|
||||||
@@ -210,7 +209,7 @@ To backup the database:
|
|||||||
```bash
|
```bash
|
||||||
mysqldump -u root -p keepitgoing > /tmp/backup.sql
|
mysqldump -u root -p keepitgoing > /tmp/backup.sql
|
||||||
```
|
```
|
||||||
4. Enter root password: `l7xF4j20HxqE2ZGNDR2FIoCCYwb6tDMq`
|
4. Enter the `DB_ROOT_PASSWORD` value from your Portainer stack environment variables
|
||||||
5. Use **File browser** to download `/tmp/backup.sql`
|
5. Use **File browser** to download `/tmp/backup.sql`
|
||||||
|
|
||||||
### Scaling
|
### Scaling
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ A powerful Django-based task management system with time tracking, tag organizat
|
|||||||
- **Priority Levels**: Low, Medium, High, and Urgent
|
- **Priority Levels**: Low, Medium, High, and Urgent
|
||||||
- **Status Tracking**: Pending, In Progress, Completed, Cancelled
|
- **Status Tracking**: Pending, In Progress, Completed, Cancelled
|
||||||
- **Due Dates & Times**: Set specific due dates and times for tasks
|
- **Due Dates & Times**: Set specific due dates and times for tasks
|
||||||
- **Recurrence**: Daily, Weekly, Bi-weekly, Monthly, Yearly, and Custom patterns
|
- **Recurrence**: Daily, Weekly, Bi-weekly, Monthly, Yearly, plus Custom RRULE patterns (e.g. every other Wednesday, every second Tuesday, every 15th of the month)
|
||||||
|
|
||||||
### Organization
|
### Organization
|
||||||
- **Tag-Based System**: Organize tasks with multiple colored tags
|
- **Tag-Based System**: Organize tasks with multiple colored tags
|
||||||
@@ -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`
|
||||||
|
|||||||
+4
-8
@@ -22,17 +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': {
|
|
||||||
'task': 'notifications.tasks.check_overdue_tasks',
|
|
||||||
'schedule': crontab(hour=8, minute=0), # Daily at 8 AM
|
|
||||||
},
|
},
|
||||||
'process-recurring-tasks': {
|
'process-recurring-tasks': {
|
||||||
'task': 'notifications.tasks.process_recurring_tasks',
|
'task': 'notifications.tasks.process_recurring_tasks',
|
||||||
'schedule': crontab(minute=0), # Every hour
|
'schedule': crontab(hour=0, minute=0), # Daily at midnight
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -163,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/'
|
||||||
|
|||||||
+30
-3
@@ -46,7 +46,7 @@ services:
|
|||||||
# =================================================================
|
# =================================================================
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: https://git.darksingularity.org/DarkSingularity/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.darksingularity.org/DarkSingularity/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.darksingularity.org/DarkSingularity/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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+105
-209
@@ -5,252 +5,149 @@ 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:
|
emails_sent = 0
|
||||||
return
|
current_utc_hour = timezone.now().hour
|
||||||
|
|
||||||
tokens = DeviceToken.objects.filter(user=user, is_active=True)
|
for user in users:
|
||||||
|
# Convert current UTC time to user's local time
|
||||||
|
try:
|
||||||
|
user_tz = ZoneInfo(user.timezone)
|
||||||
|
except Exception:
|
||||||
|
user_tz = ZoneInfo('UTC')
|
||||||
|
|
||||||
for token in tokens:
|
user_local_time = timezone.now().astimezone(user_tz)
|
||||||
if token.platform == 'android':
|
user_local_hour = user_local_time.hour
|
||||||
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
|
|
||||||
|
|
||||||
|
# Only send if it's between 6-7 AM in the user's timezone
|
||||||
|
# (gives 1 hour window, task runs every hour)
|
||||||
|
if not (6 <= user_local_hour < 7):
|
||||||
|
continue
|
||||||
|
|
||||||
@shared_task
|
# Get today's date in user's timezone
|
||||||
def send_fcm_notification(token, title, body, data=None):
|
today = user_local_time.date()
|
||||||
"""Send FCM notification to Android device."""
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
# Only send if FCM is configured
|
# Check if we already sent an email today
|
||||||
if not getattr(settings, 'FCM_CREDENTIALS_PATH', None):
|
from .models import Notification
|
||||||
return
|
already_sent_today = Notification.objects.filter(
|
||||||
|
user=user,
|
||||||
|
notification_type='daily_email',
|
||||||
|
created_at__date=today
|
||||||
|
).exists()
|
||||||
|
|
||||||
try:
|
if already_sent_today:
|
||||||
import firebase_admin
|
continue
|
||||||
from firebase_admin import credentials, messaging
|
|
||||||
|
|
||||||
# Initialize Firebase if not already done
|
# Get tasks due today
|
||||||
if not firebase_admin._apps:
|
tasks_due_today = Task.objects.filter(
|
||||||
cred = credentials.Certificate(settings.FCM_CREDENTIALS_PATH)
|
user=user,
|
||||||
firebase_admin.initialize_app(cred)
|
due_date=today,
|
||||||
|
status__in=['pending', 'in_progress']
|
||||||
|
).order_by('due_time', 'priority', 'title')
|
||||||
|
|
||||||
message = messaging.Message(
|
# Get overdue tasks
|
||||||
notification=messaging.Notification(
|
overdue_tasks = Task.objects.filter(
|
||||||
title=title,
|
user=user,
|
||||||
body=body,
|
due_date__lt=today,
|
||||||
),
|
status__in=['pending', 'in_progress']
|
||||||
data=data or {},
|
).order_by('due_date', 'due_time', 'priority', 'title')
|
||||||
token=token,
|
|
||||||
)
|
|
||||||
|
|
||||||
messaging.send(message)
|
# Only send email if there are tasks
|
||||||
|
if not tasks_due_today.exists() and not overdue_tasks.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
except Exception as e:
|
# Prepare email content
|
||||||
# Log error but don't fail
|
subject = f"Your tasks for {today.strftime('%A, %B %d, %Y')}"
|
||||||
logger.error(f"FCM notification failed: {e}")
|
|
||||||
|
|
||||||
|
# Create plain text message
|
||||||
|
message_lines = [
|
||||||
|
f"Good morning! Here are your tasks for today:\n"
|
||||||
|
]
|
||||||
|
|
||||||
@shared_task
|
if overdue_tasks.exists():
|
||||||
def send_web_push_notification(subscription_info, title, body, data=None):
|
message_lines.append(f"\n⚠️ OVERDUE TASKS ({overdue_tasks.count()}):")
|
||||||
"""Send Web Push notification."""
|
for task in overdue_tasks[:10]: # Limit to 10
|
||||||
from django.conf import settings
|
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")
|
||||||
|
|
||||||
vapid_private_key = getattr(settings, 'VAPID_PRIVATE_KEY', None)
|
if tasks_due_today.exists():
|
||||||
vapid_email = getattr(settings, 'VAPID_ADMIN_EMAIL', None)
|
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())
|
||||||
|
|
||||||
if not vapid_private_key or not vapid_email:
|
message_lines.append(f"\n\nView all tasks: https://{settings.SITE_DOMAIN}")
|
||||||
return
|
message_lines.append("\nYou can change your email preferences in your profile settings.")
|
||||||
|
|
||||||
try:
|
message = '\n'.join(message_lines)
|
||||||
from pywebpush import webpush, WebPushException
|
|
||||||
import json
|
|
||||||
|
|
||||||
webpush(
|
# Send email
|
||||||
subscription_info=json.loads(subscription_info),
|
try:
|
||||||
data=json.dumps({
|
send_mail(
|
||||||
'title': title,
|
subject=subject,
|
||||||
'body': body,
|
message=message,
|
||||||
'data': data or {}
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
}),
|
recipient_list=[user.email],
|
||||||
vapid_private_key=vapid_private_key,
|
fail_silently=False,
|
||||||
vapid_claims={'sub': f'mailto:{vapid_email}'}
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Web push notification failed: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
|
||||||
def schedule_task_reminder(task_id):
|
|
||||||
"""
|
|
||||||
Schedule a reminder for a task based on its due date and user preferences.
|
|
||||||
Called when a task is created or updated.
|
|
||||||
"""
|
|
||||||
from tasks.models import Task
|
|
||||||
from .models import ScheduledReminder
|
|
||||||
|
|
||||||
try:
|
|
||||||
task = Task.objects.get(id=task_id)
|
|
||||||
except Task.DoesNotExist:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Remove existing scheduled reminders for this task
|
|
||||||
ScheduledReminder.objects.filter(task=task, is_sent=False).delete()
|
|
||||||
|
|
||||||
# Only schedule if task has a reminder time or due date
|
|
||||||
if task.reminder_at:
|
|
||||||
ScheduledReminder.objects.create(
|
|
||||||
task=task,
|
|
||||||
remind_at=task.reminder_at
|
|
||||||
)
|
|
||||||
elif task.due_date:
|
|
||||||
# Default reminder based on user preference
|
|
||||||
reminder_minutes = task.user.default_reminder_minutes
|
|
||||||
if task.due_time:
|
|
||||||
from datetime import datetime
|
|
||||||
due_datetime = datetime.combine(task.due_date, task.due_time)
|
|
||||||
due_datetime = timezone.make_aware(due_datetime)
|
|
||||||
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 remind_at > timezone.now():
|
|
||||||
ScheduledReminder.objects.create(
|
|
||||||
task=task,
|
|
||||||
remind_at=remind_at
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
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 process_recurring_tasks():
|
def process_recurring_tasks():
|
||||||
"""
|
"""
|
||||||
Process completed recurring tasks and create next instances.
|
Process completed recurring tasks and create next instances.
|
||||||
This runs periodically as a safety net to catch any tasks that weren't
|
This runs daily as a safety net to catch any tasks that weren't
|
||||||
automatically handled when marked as completed.
|
automatically handled when marked as completed.
|
||||||
Runs every hour via Celery Beat.
|
|
||||||
"""
|
"""
|
||||||
from tasks.models import Task
|
from tasks.models import Task
|
||||||
|
|
||||||
# Find completed recurring tasks that don't have a next instance created yet
|
# Find completed recurring tasks from the last 48 hours
|
||||||
# We look for tasks completed in the last 24 hours to avoid reprocessing old tasks
|
yesterday = timezone.now() - timedelta(days=2)
|
||||||
yesterday = timezone.now() - timedelta(days=1)
|
|
||||||
|
|
||||||
completed_recurring_tasks = Task.objects.filter(
|
completed_recurring_tasks = Task.objects.filter(
|
||||||
status='completed',
|
status='completed',
|
||||||
@@ -261,16 +158,15 @@ def process_recurring_tasks():
|
|||||||
tasks_created = 0
|
tasks_created = 0
|
||||||
for task in completed_recurring_tasks:
|
for task in completed_recurring_tasks:
|
||||||
# Check if a next recurrence already exists
|
# Check if a next recurrence already exists
|
||||||
# Look for pending tasks with the same title, user, and recurrence pattern
|
|
||||||
next_due_date = task.calculate_next_due_date()
|
next_due_date = task.calculate_next_due_date()
|
||||||
if next_due_date:
|
if next_due_date:
|
||||||
# Check if we already created this recurrence
|
# Check if we already created this recurrence
|
||||||
|
# Check for any task (pending OR completed) to avoid duplicates
|
||||||
existing = Task.objects.filter(
|
existing = Task.objects.filter(
|
||||||
user=task.user,
|
user=task.user,
|
||||||
title=task.title,
|
title=task.title,
|
||||||
due_date=next_due_date,
|
due_date=next_due_date,
|
||||||
recurrence=task.recurrence,
|
recurrence=task.recurrence
|
||||||
status='pending'
|
|
||||||
).exists()
|
).exists()
|
||||||
|
|
||||||
if not existing:
|
if not existing:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|||||||
@@ -162,6 +162,74 @@ function closeDetail() {
|
|||||||
closeMobileMenus();
|
closeMobileMenus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Custom Recurrence Builder
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
function toggleCustomRecurrenceBuilder(selectEl) {
|
||||||
|
const form = selectEl.closest('form');
|
||||||
|
const builder = form.querySelector('.custom-recurrence-builder');
|
||||||
|
if (builder) {
|
||||||
|
builder.classList.toggle('hidden', selectEl.value !== 'custom');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCustomFreqPanel(radioEl) {
|
||||||
|
const builder = radioEl.closest('.custom-recurrence-builder');
|
||||||
|
const weeklyPanel = builder.querySelector('.custom-weekly-panel');
|
||||||
|
const monthlyPanel = builder.querySelector('.custom-monthly-panel');
|
||||||
|
weeklyPanel.classList.toggle('hidden', radioEl.value !== 'weekly');
|
||||||
|
monthlyPanel.classList.toggle('hidden', radioEl.value !== 'monthly');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assembleCustomRecurrenceRule(formEl) {
|
||||||
|
const recurrenceSelect = formEl.querySelector('[name="recurrence"]');
|
||||||
|
const ruleInput = formEl.querySelector('[name="recurrence_rule"]');
|
||||||
|
if (!recurrenceSelect || !ruleInput) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recurrenceSelect.value !== 'custom') {
|
||||||
|
ruleInput.value = '';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const builder = formEl.querySelector('.custom-recurrence-builder');
|
||||||
|
if (!builder) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const freqMode = builder.querySelector('.custom-freq-mode:checked');
|
||||||
|
const freq = freqMode ? freqMode.value : 'weekly';
|
||||||
|
|
||||||
|
if (freq === 'weekly') {
|
||||||
|
const panel = builder.querySelector('.custom-weekly-panel');
|
||||||
|
const interval = parseInt(panel.querySelector('.custom-interval').value, 10) || 1;
|
||||||
|
const days = Array.from(panel.querySelectorAll('.custom-weekday:checked')).map(cb => cb.value);
|
||||||
|
if (days.length === 0) {
|
||||||
|
ruleInput.value = '';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
ruleInput.value = `FREQ=WEEKLY;INTERVAL=${interval};BYDAY=${days.join(',')}`;
|
||||||
|
} else {
|
||||||
|
const panel = builder.querySelector('.custom-monthly-panel');
|
||||||
|
const interval = parseInt(panel.querySelector('.custom-interval').value, 10) || 1;
|
||||||
|
const monthlyMode = panel.querySelector('.custom-monthly-mode:checked');
|
||||||
|
const mode = monthlyMode ? monthlyMode.value : 'day';
|
||||||
|
|
||||||
|
if (mode === 'day') {
|
||||||
|
const day = parseInt(panel.querySelector('.custom-bymonthday').value, 10) || 1;
|
||||||
|
ruleInput.value = `FREQ=MONTHLY;INTERVAL=${interval};BYMONTHDAY=${day}`;
|
||||||
|
} else {
|
||||||
|
const ordinal = panel.querySelector('.custom-nth-ordinal').value;
|
||||||
|
const weekday = panel.querySelector('.custom-nth-weekday').value;
|
||||||
|
ruleInput.value = `FREQ=MONTHLY;INTERVAL=${interval};BYDAY=${ordinal}${weekday}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
Mobile Navigation
|
Mobile Navigation
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|||||||
+82
-4
@@ -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
|
||||||
@@ -202,9 +213,17 @@ class Task(models.Model):
|
|||||||
elif self.recurrence == 'yearly':
|
elif self.recurrence == 'yearly':
|
||||||
next_date = base_date + relativedelta(years=1)
|
next_date = base_date + relativedelta(years=1)
|
||||||
elif self.recurrence == 'custom' and self.recurrence_rule:
|
elif self.recurrence == 'custom' and self.recurrence_rule:
|
||||||
# TODO: Implement RRULE parsing for custom recurrence
|
from dateutil.rrule import rrulestr
|
||||||
# For now, default to weekly
|
from datetime import datetime, time as dt_time
|
||||||
next_date = base_date + timedelta(weeks=1)
|
|
||||||
|
dtstart = datetime.combine(base_date, dt_time.min)
|
||||||
|
try:
|
||||||
|
next_occurrence = rrulestr(self.recurrence_rule, dtstart=dtstart).after(dtstart, inc=False)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not next_occurrence:
|
||||||
|
return None
|
||||||
|
next_date = next_occurrence.date()
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -214,6 +233,65 @@ class Task(models.Model):
|
|||||||
|
|
||||||
return next_date
|
return next_date
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parsed_custom_recurrence(self):
|
||||||
|
"""
|
||||||
|
Decompose recurrence_rule (an RRULE string) into simple fields for
|
||||||
|
prepopulating the custom recurrence builder UI. Never raises; returns
|
||||||
|
safe defaults for a blank or malformed rule.
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
'freq': None,
|
||||||
|
'interval': 1,
|
||||||
|
'byweekday': [],
|
||||||
|
'monthly_mode': None,
|
||||||
|
'bymonthday': None,
|
||||||
|
'nth_ordinal': None,
|
||||||
|
'nth_weekday': None,
|
||||||
|
}
|
||||||
|
if not self.recurrence_rule:
|
||||||
|
return result
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
params = {}
|
||||||
|
for part in self.recurrence_rule.split(';'):
|
||||||
|
if '=' in part:
|
||||||
|
key, value = part.split('=', 1)
|
||||||
|
params[key.strip().upper()] = value.strip()
|
||||||
|
|
||||||
|
freq = params.get('FREQ', '').upper()
|
||||||
|
if freq not in ('WEEKLY', 'MONTHLY'):
|
||||||
|
return result
|
||||||
|
result['freq'] = freq.lower()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result['interval'] = int(params.get('INTERVAL', '1'))
|
||||||
|
except ValueError:
|
||||||
|
result['interval'] = 1
|
||||||
|
|
||||||
|
byday = params.get('BYDAY', '')
|
||||||
|
|
||||||
|
if freq == 'WEEKLY':
|
||||||
|
if byday:
|
||||||
|
result['byweekday'] = [d.strip() for d in byday.split(',') if d.strip()]
|
||||||
|
elif freq == 'MONTHLY':
|
||||||
|
bymonthday = params.get('BYMONTHDAY')
|
||||||
|
if bymonthday:
|
||||||
|
try:
|
||||||
|
result['bymonthday'] = int(bymonthday)
|
||||||
|
result['monthly_mode'] = 'day'
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
elif byday:
|
||||||
|
match = re.match(r'^(-?\d+)([A-Z]{2})$', byday)
|
||||||
|
if match:
|
||||||
|
result['nth_ordinal'] = int(match.group(1))
|
||||||
|
result['nth_weekday'] = match.group(2)
|
||||||
|
result['monthly_mode'] = 'nth'
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
def create_next_recurrence(self):
|
def create_next_recurrence(self):
|
||||||
"""
|
"""
|
||||||
Create the next instance of this recurring task.
|
Create the next instance of this recurring task.
|
||||||
|
|||||||
+85
-1
@@ -1,3 +1,87 @@
|
|||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
# Create your tests here.
|
from tasks.models import Task
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class CustomRecurrenceTests(TestCase):
|
||||||
|
"""Tests for Task.calculate_next_due_date() with custom RRULE patterns."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username='testuser',
|
||||||
|
email='testuser@example.com',
|
||||||
|
password='testpass123',
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_task(self, **kwargs):
|
||||||
|
defaults = {
|
||||||
|
'user': self.user,
|
||||||
|
'title': 'Test task',
|
||||||
|
'recurrence': 'custom',
|
||||||
|
}
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return Task.objects.create(**defaults)
|
||||||
|
|
||||||
|
def test_every_wednesday(self):
|
||||||
|
# 2026-01-07 is a Wednesday
|
||||||
|
task = self.make_task(due_date=date(2026, 1, 7), recurrence_rule='FREQ=WEEKLY;BYDAY=WE')
|
||||||
|
self.assertEqual(task.calculate_next_due_date(), date(2026, 1, 14))
|
||||||
|
|
||||||
|
def test_every_other_wednesday(self):
|
||||||
|
task = self.make_task(due_date=date(2026, 1, 7), recurrence_rule='FREQ=WEEKLY;INTERVAL=2;BYDAY=WE')
|
||||||
|
self.assertEqual(task.calculate_next_due_date(), date(2026, 1, 21))
|
||||||
|
|
||||||
|
def test_every_second_tuesday(self):
|
||||||
|
# 2026-01-13 is the second Tuesday of January 2026
|
||||||
|
task = self.make_task(due_date=date(2026, 1, 13), recurrence_rule='FREQ=MONTHLY;BYDAY=2TU')
|
||||||
|
self.assertEqual(task.calculate_next_due_date(), date(2026, 2, 10))
|
||||||
|
|
||||||
|
def test_every_15th(self):
|
||||||
|
task = self.make_task(due_date=date(2026, 1, 15), recurrence_rule='FREQ=MONTHLY;BYMONTHDAY=15')
|
||||||
|
self.assertEqual(task.calculate_next_due_date(), date(2026, 2, 15))
|
||||||
|
|
||||||
|
def test_respects_recurrence_end_date(self):
|
||||||
|
task = self.make_task(
|
||||||
|
due_date=date(2026, 1, 7),
|
||||||
|
recurrence_rule='FREQ=WEEKLY;BYDAY=WE',
|
||||||
|
recurrence_end_date=date(2026, 1, 10),
|
||||||
|
)
|
||||||
|
self.assertIsNone(task.calculate_next_due_date())
|
||||||
|
|
||||||
|
def test_malformed_rule_returns_none(self):
|
||||||
|
task = self.make_task(due_date=date(2026, 1, 7), recurrence_rule='not a valid rrule')
|
||||||
|
self.assertIsNone(task.calculate_next_due_date())
|
||||||
|
|
||||||
|
def test_create_next_recurrence(self):
|
||||||
|
task = self.make_task(due_date=date(2026, 1, 15), recurrence_rule='FREQ=MONTHLY;BYMONTHDAY=15')
|
||||||
|
new_task = task.create_next_recurrence()
|
||||||
|
self.assertIsNotNone(new_task)
|
||||||
|
self.assertEqual(new_task.due_date, date(2026, 2, 15))
|
||||||
|
self.assertEqual(new_task.recurrence_rule, 'FREQ=MONTHLY;BYMONTHDAY=15')
|
||||||
|
self.assertEqual(new_task.status, 'pending')
|
||||||
|
|
||||||
|
def test_parsed_custom_recurrence_weekly(self):
|
||||||
|
task = self.make_task(recurrence_rule='FREQ=WEEKLY;INTERVAL=2;BYDAY=WE')
|
||||||
|
parsed = task.parsed_custom_recurrence
|
||||||
|
self.assertEqual(parsed['freq'], 'weekly')
|
||||||
|
self.assertEqual(parsed['interval'], 2)
|
||||||
|
self.assertEqual(parsed['byweekday'], ['WE'])
|
||||||
|
|
||||||
|
def test_parsed_custom_recurrence_monthly_nth(self):
|
||||||
|
task = self.make_task(recurrence_rule='FREQ=MONTHLY;BYDAY=2TU')
|
||||||
|
parsed = task.parsed_custom_recurrence
|
||||||
|
self.assertEqual(parsed['freq'], 'monthly')
|
||||||
|
self.assertEqual(parsed['monthly_mode'], 'nth')
|
||||||
|
self.assertEqual(parsed['nth_ordinal'], 2)
|
||||||
|
self.assertEqual(parsed['nth_weekday'], 'TU')
|
||||||
|
|
||||||
|
def test_parsed_custom_recurrence_blank(self):
|
||||||
|
task = self.make_task(recurrence_rule='')
|
||||||
|
parsed = task.parsed_custom_recurrence
|
||||||
|
self.assertIsNone(parsed['freq'])
|
||||||
|
self.assertEqual(parsed['byweekday'], [])
|
||||||
|
|||||||
+38
-10
@@ -218,13 +218,21 @@ class DashboardView(View):
|
|||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return redirect('login')
|
return redirect('login')
|
||||||
|
|
||||||
today = timezone.now().date()
|
# 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()
|
||||||
|
|
||||||
# Get filter parameters
|
# Get filter parameters
|
||||||
current_filter = request.GET.get('filter', 'all')
|
current_filter = request.GET.get('filter', 'all')
|
||||||
current_tag_id = request.GET.get('tag')
|
current_tag_id = request.GET.get('tag')
|
||||||
selected_task_id = request.GET.get('selected')
|
selected_task_id = request.GET.get('selected')
|
||||||
current_sort = request.GET.get('sort', 'default')
|
current_sort = request.GET.get('sort', 'due_date')
|
||||||
|
|
||||||
# Base query
|
# Base query
|
||||||
tasks = Task.objects.filter(
|
tasks = Task.objects.filter(
|
||||||
@@ -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':
|
||||||
@@ -454,6 +479,8 @@ class TaskDetailView(View):
|
|||||||
else:
|
else:
|
||||||
task.recurrence = 'none'
|
task.recurrence = 'none'
|
||||||
|
|
||||||
|
task.recurrence_rule = request.POST.get('recurrence_rule', '') if task.recurrence == 'custom' else ''
|
||||||
|
|
||||||
# Create next recurrence if task is being marked as completed
|
# Create next recurrence if task is being marked as completed
|
||||||
if old_status != 'completed' and task.status == 'completed' and task.recurrence != 'none':
|
if old_status != 'completed' and task.status == 'completed' and task.recurrence != 'none':
|
||||||
task.create_next_recurrence()
|
task.create_next_recurrence()
|
||||||
@@ -516,6 +543,7 @@ class TaskCreateView(View):
|
|||||||
due_date=request.POST.get('due_date') or None,
|
due_date=request.POST.get('due_date') or None,
|
||||||
due_time=request.POST.get('due_time') or None,
|
due_time=request.POST.get('due_time') or None,
|
||||||
recurrence=recurrence,
|
recurrence=recurrence,
|
||||||
|
recurrence_rule=request.POST.get('recurrence_rule', '') if recurrence == 'custom' else '',
|
||||||
)
|
)
|
||||||
|
|
||||||
tag_ids = request.POST.getlist('tags')
|
tag_ids = request.POST.getlist('tags')
|
||||||
|
|||||||
+1
-1
@@ -140,7 +140,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
<script src="{% static 'js/app.js' %}?v=3"></script>
|
<script src="{% static 'js/app.js' %}?v=4"></script>
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<!-- Recurrence -->
|
||||||
|
{% with parsed=task.parsed_custom_recurrence %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="{{ id_prefix }}recurrence">Recurrence</label>
|
||||||
|
<select class="form-select" id="{{ id_prefix }}recurrence" name="recurrence" onchange="toggleCustomRecurrenceBuilder(this)">
|
||||||
|
<option value="none" {% if not task or task.recurrence == 'none' %}selected{% endif %}>None</option>
|
||||||
|
<option value="daily" {% if task.recurrence == 'daily' %}selected{% endif %}>Daily</option>
|
||||||
|
<option value="weekly" {% if task.recurrence == 'weekly' %}selected{% endif %}>Weekly</option>
|
||||||
|
<option value="biweekly" {% if task.recurrence == 'biweekly' %}selected{% endif %}>Bi-weekly</option>
|
||||||
|
<option value="monthly" {% if task.recurrence == 'monthly' %}selected{% endif %}>Monthly</option>
|
||||||
|
<option value="yearly" {% if task.recurrence == 'yearly' %}selected{% endif %}>Yearly</option>
|
||||||
|
<option value="custom" {% if task.recurrence == 'custom' %}selected{% endif %}>Custom</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" name="recurrence_rule" value="{{ task.recurrence_rule|default:'' }}">
|
||||||
|
|
||||||
|
<div class="custom-recurrence-builder form-group {% if not task or task.recurrence != 'custom' %}hidden{% endif %}" style="background: var(--bg); border-radius: var(--radius-sm); padding: var(--space-md);">
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer; margin-bottom: var(--space-xs);">
|
||||||
|
<input type="radio" class="custom-freq-mode" value="weekly" onchange="toggleCustomFreqPanel(this)" {% if parsed.freq != 'monthly' %}checked{% endif %}>
|
||||||
|
Weekly
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
|
||||||
|
<input type="radio" class="custom-freq-mode" value="monthly" onchange="toggleCustomFreqPanel(this)" {% if parsed.freq == 'monthly' %}checked{% endif %}>
|
||||||
|
Monthly
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Weekly panel -->
|
||||||
|
<div class="custom-weekly-panel {% if parsed.freq == 'monthly' %}hidden{% endif %}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Every
|
||||||
|
<input type="number" class="custom-interval" min="1" value="{{ parsed.interval|default:1 }}" style="width: 60px;">
|
||||||
|
week(s) on:
|
||||||
|
</label>
|
||||||
|
<div style="display: flex; flex-wrap: wrap; gap: var(--space-sm);">
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
|
||||||
|
<input type="checkbox" class="custom-weekday" value="SU" {% if "SU" in parsed.byweekday %}checked{% endif %}> Su
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
|
||||||
|
<input type="checkbox" class="custom-weekday" value="MO" {% if "MO" in parsed.byweekday %}checked{% endif %}> Mo
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
|
||||||
|
<input type="checkbox" class="custom-weekday" value="TU" {% if "TU" in parsed.byweekday %}checked{% endif %}> Tu
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
|
||||||
|
<input type="checkbox" class="custom-weekday" value="WE" {% if "WE" in parsed.byweekday %}checked{% endif %}> We
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
|
||||||
|
<input type="checkbox" class="custom-weekday" value="TH" {% if "TH" in parsed.byweekday %}checked{% endif %}> Th
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
|
||||||
|
<input type="checkbox" class="custom-weekday" value="FR" {% if "FR" in parsed.byweekday %}checked{% endif %}> Fr
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
|
||||||
|
<input type="checkbox" class="custom-weekday" value="SA" {% if "SA" in parsed.byweekday %}checked{% endif %}> Sa
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Monthly panel -->
|
||||||
|
<div class="custom-monthly-panel {% if parsed.freq != 'monthly' %}hidden{% endif %}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Every
|
||||||
|
<input type="number" class="custom-interval" min="1" value="{{ parsed.interval|default:1 }}" style="width: 60px;">
|
||||||
|
month(s):
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer; margin-bottom: var(--space-xs);">
|
||||||
|
<input type="radio" class="custom-monthly-mode" value="day" {% if parsed.monthly_mode != 'nth' %}checked{% endif %}>
|
||||||
|
On day
|
||||||
|
<input type="number" class="custom-bymonthday" min="1" max="31" value="{{ parsed.bymonthday|default:1 }}" style="width: 60px;">
|
||||||
|
of the month
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
|
||||||
|
<input type="radio" class="custom-monthly-mode" value="nth" {% if parsed.monthly_mode == 'nth' %}checked{% endif %}>
|
||||||
|
On the
|
||||||
|
<select class="custom-nth-ordinal form-select" style="width: auto;">
|
||||||
|
<option value="1" {% if parsed.nth_ordinal == 1 %}selected{% endif %}>First</option>
|
||||||
|
<option value="2" {% if parsed.nth_ordinal == 2 %}selected{% endif %}>Second</option>
|
||||||
|
<option value="3" {% if parsed.nth_ordinal == 3 %}selected{% endif %}>Third</option>
|
||||||
|
<option value="4" {% if parsed.nth_ordinal == 4 %}selected{% endif %}>Fourth</option>
|
||||||
|
<option value="-1" {% if parsed.nth_ordinal == -1 %}selected{% endif %}>Last</option>
|
||||||
|
</select>
|
||||||
|
<select class="custom-nth-weekday form-select" style="width: auto;">
|
||||||
|
<option value="SU" {% if parsed.nth_weekday == "SU" %}selected{% endif %}>Sunday</option>
|
||||||
|
<option value="MO" {% if parsed.nth_weekday == "MO" %}selected{% endif %}>Monday</option>
|
||||||
|
<option value="TU" {% if parsed.nth_weekday == "TU" %}selected{% endif %}>Tuesday</option>
|
||||||
|
<option value="WE" {% if parsed.nth_weekday == "WE" %}selected{% endif %}>Wednesday</option>
|
||||||
|
<option value="TH" {% if parsed.nth_weekday == "TH" %}selected{% endif %}>Thursday</option>
|
||||||
|
<option value="FR" {% if parsed.nth_weekday == "FR" %}selected{% endif %}>Friday</option>
|
||||||
|
<option value="SA" {% if parsed.nth_weekday == "SA" %}selected{% endif %}>Saturday</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="{% url 'task-detail' task.id %}" id="task-detail-form">
|
<form method="post" action="{% url 'task-detail' task.id %}" id="task-detail-form" onsubmit="assembleCustomRecurrenceRule(this)">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="next" value="{% url 'dashboard' %}?selected={{ task.id }}{% if request.GET.filter %}&filter={{ request.GET.filter }}{% endif %}{% if request.GET.tag %}&tag={{ request.GET.tag }}{% endif %}">
|
<input type="hidden" name="next" value="{% url 'dashboard' %}?selected={{ task.id }}{% if request.GET.filter %}&filter={{ request.GET.filter }}{% endif %}{% if request.GET.tag %}&tag={{ request.GET.tag }}{% endif %}">
|
||||||
|
|
||||||
@@ -75,18 +75,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Recurrence -->
|
{% include 'tasks/_recurrence_fields.html' with id_prefix='detail-' task=task %}
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" for="detail-recurrence">Recurrence</label>
|
|
||||||
<select class="form-select" id="detail-recurrence" name="recurrence">
|
|
||||||
<option value="none" {% if task.recurrence == 'none' %}selected{% endif %}>None</option>
|
|
||||||
<option value="daily" {% if task.recurrence == 'daily' %}selected{% endif %}>Daily</option>
|
|
||||||
<option value="weekly" {% if task.recurrence == 'weekly' %}selected{% endif %}>Weekly</option>
|
|
||||||
<option value="biweekly" {% if task.recurrence == 'biweekly' %}selected{% endif %}>Bi-weekly</option>
|
|
||||||
<option value="monthly" {% if task.recurrence == 'monthly' %}selected{% endif %}>Monthly</option>
|
|
||||||
<option value="yearly" {% if task.recurrence == 'yearly' %}selected{% endif %}>Yearly</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Save Button (moved inside form, before subtasks) -->
|
<!-- Save Button (moved inside form, before subtasks) -->
|
||||||
<div class="detail-actions" style="margin-bottom: var(--space-lg);">
|
<div class="detail-actions" style="margin-bottom: var(--space-lg);">
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
<div style="display: flex; align-items: center; gap: var(--space-md);">
|
<div style="display: flex; align-items: center; gap: var(--space-md);">
|
||||||
<span class="task-count">{{ tasks|length }} task{{ tasks|length|pluralize }}</span>
|
<span class="task-count">{{ tasks|length }} task{{ tasks|length|pluralize }}</span>
|
||||||
<select id="sort-select" onchange="updateSort()" style="padding: 4px 8px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--text);">
|
<select id="sort-select" onchange="updateSort()" style="padding: 4px 8px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--text);">
|
||||||
<option value="default" {% if current_sort == 'default' %}selected{% endif %}>Default</option>
|
|
||||||
<option value="due_date" {% if current_sort == 'due_date' %}selected{% endif %}>Due Date (Earliest)</option>
|
<option value="due_date" {% if current_sort == 'due_date' %}selected{% endif %}>Due Date (Earliest)</option>
|
||||||
<option value="due_date_desc" {% if current_sort == 'due_date_desc' %}selected{% endif %}>Due Date (Latest)</option>
|
<option value="due_date_desc" {% if current_sort == 'due_date_desc' %}selected{% endif %}>Due Date (Latest)</option>
|
||||||
<option value="priority" {% if current_sort == 'priority' %}selected{% endif %}>Priority (High to Low)</option>
|
<option value="priority" {% if current_sort == 'priority' %}selected{% endif %}>Priority (High to Low)</option>
|
||||||
@@ -76,7 +75,7 @@ if (timerDisplay) {
|
|||||||
function updateSort() {
|
function updateSort() {
|
||||||
const sortValue = document.getElementById('sort-select').value;
|
const sortValue = document.getElementById('sort-select').value;
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
if (sortValue === 'default') {
|
if (sortValue === 'due_date') {
|
||||||
url.searchParams.delete('sort');
|
url.searchParams.delete('sort');
|
||||||
} else {
|
} else {
|
||||||
url.searchParams.set('sort', sortValue);
|
url.searchParams.set('sort', sortValue);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-xl); max-width: 600px;">
|
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-xl); max-width: 600px;">
|
||||||
<form method="post">
|
<form method="post" onsubmit="assembleCustomRecurrenceRule(this)">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -55,17 +55,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
{% include 'tasks/_recurrence_fields.html' with id_prefix='' %}
|
||||||
<label class="form-label" for="recurrence">Recurrence</label>
|
|
||||||
<select class="form-select" id="recurrence" name="recurrence">
|
|
||||||
<option value="none" selected>None</option>
|
|
||||||
<option value="daily">Daily</option>
|
|
||||||
<option value="weekly">Weekly</option>
|
|
||||||
<option value="biweekly">Bi-weekly</option>
|
|
||||||
<option value="monthly">Monthly</option>
|
|
||||||
<option value="yearly">Yearly</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if tags %}
|
{% if tags %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<div style="display: grid; grid-template-columns: 1fr 350px; gap: var(--space-lg);">
|
<div style="display: grid; grid-template-columns: 1fr 350px; gap: var(--space-lg);">
|
||||||
<!-- Main Form -->
|
<!-- Main Form -->
|
||||||
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-xl);">
|
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-xl);">
|
||||||
<form method="post">
|
<form method="post" onsubmit="assembleCustomRecurrenceRule(this)">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -81,31 +81,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="form-group">
|
{% include 'tasks/_recurrence_fields.html' with id_prefix='' task=task %}
|
||||||
<label class="form-label" for="recurrence">Recurrence</label>
|
|
||||||
<select class="form-select" id="recurrence" name="recurrence">
|
|
||||||
<option value="none" {% if task.recurrence == 'none' %}selected{% endif %}>None</option>
|
|
||||||
<option value="daily" {% if task.recurrence == 'daily' %}selected{% endif %}>Daily</option>
|
|
||||||
<option value="weekly" {% if task.recurrence == 'weekly' %}selected{% endif %}>Weekly</option>
|
|
||||||
<option value="biweekly" {% if task.recurrence == 'biweekly' %}selected{% endif %}>Bi-weekly</option>
|
|
||||||
<option value="monthly" {% if task.recurrence == 'monthly' %}selected{% endif %}>Monthly</option>
|
|
||||||
<option value="yearly" {% if task.recurrence == 'yearly' %}selected{% endif %}>Yearly</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if tags %}
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">Tags</label>
|
|
||||||
<div style="display: flex; flex-wrap: wrap; gap: var(--space-sm);">
|
|
||||||
{% for tag in tags %}
|
|
||||||
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
|
|
||||||
<input type="checkbox" name="tags" value="{{ tag.id }}" {% if tag in task.tags.all %}checked{% endif %}>
|
|
||||||
<span style="color: {{ tag.color }}">{{ tag.name }}</span>
|
|
||||||
</label>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
+39
-12
@@ -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,18 +44,18 @@ class UsersAPIRoot(APIView):
|
|||||||
permission_classes = [permissions.AllowAny]
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
return Response({
|
endpoints = {
|
||||||
'endpoints': {
|
'login': '/api/users/token/',
|
||||||
'register': '/api/users/register/',
|
'refresh_token': '/api/users/token/refresh/',
|
||||||
'login': '/api/users/token/',
|
'verify_email': '/api/users/verify-email/',
|
||||||
'refresh_token': '/api/users/token/refresh/',
|
'resend_verification': '/api/users/resend-verification/',
|
||||||
'verify_email': '/api/users/verify-email/',
|
'profile': '/api/users/profile/',
|
||||||
'resend_verification': '/api/users/resend-verification/',
|
'change_password': '/api/users/change-password/',
|
||||||
'profile': '/api/users/profile/',
|
'devices': '/api/users/devices/',
|
||||||
'change_password': '/api/users/change-password/',
|
}
|
||||||
'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')
|
||||||
|
|||||||
Reference in New Issue
Block a user