Internal
Public Access
Initial commit: KeepItGoing task management server
Features: - Django-based REST API with web interface - Task management with tags, priorities, and due dates - Time tracking with start/stop timers - Subtasks support - Task filtering (all, today, upcoming, overdue, completed) - Tag-based organization with color coding - Sorting by due date and priority - Auto-assign tags when filtering - Responsive 3-pane layout (sidebar, task list, detail panel) - Task sharing between users - Mobile-responsive design with dark mode support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
# Django settings
|
||||
DEBUG=True
|
||||
SECRET_KEY=your-secret-key-change-in-production
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgres://keepitgoing:keepitgoing@localhost:5432/keepitgoing
|
||||
|
||||
# Redis (for Celery and caching)
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# CORS (comma-separated origins)
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
|
||||
# SaaS mode (enables billing and usage limits)
|
||||
SAAS_MODE=False
|
||||
|
||||
# Push notifications
|
||||
FCM_CREDENTIALS_PATH=
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_ADMIN_EMAIL=
|
||||
|
||||
# Email (for password reset, etc.)
|
||||
EMAIL_HOST=
|
||||
EMAIL_PORT=587
|
||||
EMAIL_HOST_USER=
|
||||
EMAIL_HOST_PASSWORD=
|
||||
EMAIL_USE_TLS=True
|
||||
DEFAULT_FROM_EMAIL=noreply@keepitgoing.app
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
env/
|
||||
.venv/
|
||||
ENV/
|
||||
|
||||
# Django
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
media/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
*.env.local
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Static files (collected)
|
||||
staticfiles/
|
||||
|
||||
# Coverage
|
||||
htmlcov/
|
||||
.coverage
|
||||
.coverage.*
|
||||
|
||||
# Firebase credentials
|
||||
*firebase*.json
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# KeepItGoing Server Dockerfile
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Set work directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libpq-dev \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy project
|
||||
COPY . .
|
||||
|
||||
# Collect static files
|
||||
RUN python manage.py collectstatic --noinput --settings=config.settings.selfhosted || true
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system appgroup && adduser --system --group appuser
|
||||
RUN chown -R appuser:appgroup /app
|
||||
USER appuser
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run gunicorn
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "config.wsgi:application"]
|
||||
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Django config package.
|
||||
|
||||
This will ensure the Celery app is loaded when Django starts.
|
||||
"""
|
||||
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ('celery_app',)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for config project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
Celery configuration for KeepItGoing.
|
||||
"""
|
||||
|
||||
import os
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
|
||||
# Set the default Django settings module
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
|
||||
app = Celery('keepitgoing')
|
||||
|
||||
# Load config from Django settings
|
||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||
|
||||
# Auto-discover tasks in all installed apps
|
||||
app.autodiscover_tasks()
|
||||
|
||||
# Celery Beat schedule for periodic tasks
|
||||
app.conf.beat_schedule = {
|
||||
'send-due-reminders': {
|
||||
'task': 'notifications.tasks.send_due_reminders',
|
||||
'schedule': crontab(minute='*/5'), # Every 5 minutes
|
||||
},
|
||||
'check-overdue-tasks': {
|
||||
'task': 'notifications.tasks.check_overdue_tasks',
|
||||
'schedule': crontab(hour=8, minute=0), # Daily at 8 AM
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.task(bind=True)
|
||||
def debug_task(self):
|
||||
print(f'Request: {self.request!r}')
|
||||
@@ -0,0 +1,11 @@
|
||||
# Settings module - import from environment-specific settings
|
||||
import os
|
||||
|
||||
environment = os.environ.get('DJANGO_ENV', 'development')
|
||||
|
||||
if environment == 'production':
|
||||
from .production import *
|
||||
elif environment == 'selfhosted':
|
||||
from .selfhosted import *
|
||||
else:
|
||||
from .development import *
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Base Django settings for KeepItGoing.
|
||||
|
||||
These settings are shared across all environments.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from datetime import timedelta
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
# Application definition
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
|
||||
# Third party
|
||||
'rest_framework',
|
||||
'rest_framework_simplejwt',
|
||||
'corsheaders',
|
||||
'django_filters',
|
||||
'django_celery_beat',
|
||||
|
||||
# Local apps
|
||||
'users',
|
||||
'tasks',
|
||||
'sync',
|
||||
'notifications',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'config.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [BASE_DIR / 'templates'],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'config.wsgi.application'
|
||||
|
||||
# Custom user model
|
||||
AUTH_USER_MODEL = 'users.User'
|
||||
|
||||
# Password validation
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
# Internationalization
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
TIME_ZONE = 'UTC'
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
STATICFILES_DIRS = [BASE_DIR / 'static']
|
||||
|
||||
# Media files (user uploads)
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
# Default primary key field type
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
# Django REST Framework
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
],
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
],
|
||||
'DEFAULT_FILTER_BACKENDS': [
|
||||
'django_filters.rest_framework.DjangoFilterBackend',
|
||||
'rest_framework.filters.SearchFilter',
|
||||
'rest_framework.filters.OrderingFilter',
|
||||
],
|
||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||
'PAGE_SIZE': 50,
|
||||
}
|
||||
|
||||
# JWT Settings
|
||||
SIMPLE_JWT = {
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
|
||||
'ROTATE_REFRESH_TOKENS': True,
|
||||
'BLACKLIST_AFTER_ROTATION': True,
|
||||
'UPDATE_LAST_LOGIN': True,
|
||||
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||
}
|
||||
|
||||
# Celery Configuration
|
||||
CELERY_TIMEZONE = 'UTC'
|
||||
CELERY_TASK_TRACK_STARTED = True
|
||||
CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 minutes
|
||||
CELERY_ACCEPT_CONTENT = ['json']
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
CELERY_RESULT_SERIALIZER = 'json'
|
||||
|
||||
# Feature flags
|
||||
SAAS_MODE = os.environ.get('SAAS_MODE', 'False').lower() == 'true'
|
||||
BILLING_ENABLED = SAAS_MODE
|
||||
USAGE_LIMITS_ENABLED = SAAS_MODE
|
||||
|
||||
# Login settings
|
||||
LOGIN_URL = '/login/'
|
||||
LOGIN_REDIRECT_URL = '/'
|
||||
LOGOUT_REDIRECT_URL = '/login/'
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Development settings for KeepItGoing.
|
||||
|
||||
Use this for local development.
|
||||
"""
|
||||
|
||||
import os
|
||||
from .base import *
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = os.environ.get(
|
||||
'SECRET_KEY',
|
||||
'django-insecure-dev-key-change-this-in-production-abc123xyz'
|
||||
)
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = ['localhost', '127.0.0.1', '[::1]', '10.0.2.2', '192.168.1.241', 'tasks.firebugit.com']
|
||||
|
||||
# CSRF - trust HTTPS origin
|
||||
CSRF_TRUSTED_ORIGINS = ['https://tasks.firebugit.com']
|
||||
|
||||
# Database - SQLite for development
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
}
|
||||
}
|
||||
|
||||
# Redis/Celery - use memory broker for development if Redis not available
|
||||
CELERY_BROKER_URL = os.environ.get('REDIS_URL', 'memory://')
|
||||
CELERY_RESULT_BACKEND = os.environ.get('REDIS_URL', 'cache+memory://')
|
||||
|
||||
# CORS - allow all in development
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
|
||||
# Email - console backend for development
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
|
||||
# Logging
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'verbose': {
|
||||
'format': '{levelname} {asctime} {module} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
},
|
||||
'root': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
},
|
||||
'loggers': {
|
||||
'django': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Production settings for KeepItGoing SaaS.
|
||||
|
||||
Use this for the hosted SaaS deployment.
|
||||
"""
|
||||
|
||||
import os
|
||||
from .base import *
|
||||
|
||||
# Security
|
||||
SECRET_KEY = os.environ['SECRET_KEY']
|
||||
DEBUG = False
|
||||
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split(',')
|
||||
|
||||
# Force HTTPS
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
SECURE_SSL_REDIRECT = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SECURE_HSTS_SECONDS = 31536000 # 1 year
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_HSTS_PRELOAD = True
|
||||
|
||||
# Database
|
||||
import dj_database_url
|
||||
DATABASES = {
|
||||
'default': dj_database_url.config(
|
||||
default=os.environ.get('DATABASE_URL'),
|
||||
conn_max_age=600,
|
||||
conn_health_checks=True,
|
||||
)
|
||||
}
|
||||
|
||||
# Redis/Celery
|
||||
CELERY_BROKER_URL = os.environ['REDIS_URL']
|
||||
CELERY_RESULT_BACKEND = os.environ['REDIS_URL']
|
||||
|
||||
# Cache
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
|
||||
'LOCATION': os.environ['REDIS_URL'],
|
||||
}
|
||||
}
|
||||
|
||||
# CORS
|
||||
CORS_ALLOWED_ORIGINS = os.environ.get('CORS_ALLOWED_ORIGINS', '').split(',')
|
||||
|
||||
# Static files
|
||||
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
|
||||
|
||||
# Email
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||
EMAIL_HOST = os.environ.get('EMAIL_HOST', '')
|
||||
EMAIL_PORT = int(os.environ.get('EMAIL_PORT', 587))
|
||||
EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '')
|
||||
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '')
|
||||
EMAIL_USE_TLS = os.environ.get('EMAIL_USE_TLS', 'True').lower() == 'true'
|
||||
DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'noreply@keepitgoing.app')
|
||||
|
||||
# SaaS mode enabled
|
||||
SAAS_MODE = True
|
||||
BILLING_ENABLED = True
|
||||
USAGE_LIMITS_ENABLED = True
|
||||
|
||||
# Logging
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'verbose': {
|
||||
'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
},
|
||||
'root': {
|
||||
'handlers': ['console'],
|
||||
'level': 'WARNING',
|
||||
},
|
||||
'loggers': {
|
||||
'django': {
|
||||
'handlers': ['console'],
|
||||
'level': 'WARNING',
|
||||
'propagate': False,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Self-hosted settings for KeepItGoing.
|
||||
|
||||
Use this for users running their own instance.
|
||||
"""
|
||||
|
||||
import os
|
||||
from .base import *
|
||||
|
||||
# Security
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'change-this-secret-key-in-production')
|
||||
DEBUG = os.environ.get('DEBUG', 'False').lower() == 'true'
|
||||
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
|
||||
|
||||
# Database - PostgreSQL for self-hosted
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': os.environ.get('POSTGRES_DB', 'keepitgoing'),
|
||||
'USER': os.environ.get('POSTGRES_USER', 'keepitgoing'),
|
||||
'PASSWORD': os.environ.get('POSTGRES_PASSWORD', 'keepitgoing'),
|
||||
'HOST': os.environ.get('POSTGRES_HOST', 'db'),
|
||||
'PORT': os.environ.get('POSTGRES_PORT', '5432'),
|
||||
}
|
||||
}
|
||||
|
||||
# Redis/Celery
|
||||
REDIS_URL = os.environ.get('REDIS_URL', 'redis://redis:6379/0')
|
||||
CELERY_BROKER_URL = REDIS_URL
|
||||
CELERY_RESULT_BACKEND = REDIS_URL
|
||||
|
||||
# Cache
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
|
||||
'LOCATION': REDIS_URL,
|
||||
}
|
||||
}
|
||||
|
||||
# CORS - configurable for self-hosted
|
||||
CORS_ALLOWED_ORIGINS = os.environ.get(
|
||||
'CORS_ALLOWED_ORIGINS',
|
||||
'http://localhost:8000'
|
||||
).split(',')
|
||||
|
||||
# Static files
|
||||
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
|
||||
|
||||
# Email - configurable
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||
EMAIL_HOST = os.environ.get('EMAIL_HOST', '')
|
||||
EMAIL_PORT = int(os.environ.get('EMAIL_PORT', 587))
|
||||
EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '')
|
||||
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '')
|
||||
EMAIL_USE_TLS = os.environ.get('EMAIL_USE_TLS', 'True').lower() == 'true'
|
||||
DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'noreply@localhost')
|
||||
|
||||
# Self-hosted mode - no billing
|
||||
SAAS_MODE = False
|
||||
BILLING_ENABLED = False
|
||||
USAGE_LIMITS_ENABLED = False
|
||||
|
||||
# Logging
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'verbose': {
|
||||
'format': '{levelname} {asctime} {module} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
},
|
||||
'root': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
URL configuration for KeepItGoing.
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
|
||||
from users.urls import api_urlpatterns as users_api_urls, web_urlpatterns as users_web_urls
|
||||
from tasks.urls import api_urlpatterns as tasks_api_urls, web_urlpatterns as tasks_web_urls
|
||||
from sync.urls import api_urlpatterns as sync_api_urls
|
||||
from notifications.urls import api_urlpatterns as notifications_api_urls
|
||||
|
||||
urlpatterns = [
|
||||
# Admin
|
||||
path('admin/', admin.site.urls),
|
||||
|
||||
# API endpoints
|
||||
path('api/users/', include(users_api_urls)),
|
||||
path('api/tasks/', include(tasks_api_urls)),
|
||||
path('api/sync/', include(sync_api_urls)),
|
||||
path('api/notifications/', include(notifications_api_urls)),
|
||||
|
||||
# Web views
|
||||
path('', include(tasks_web_urls)), # Dashboard at root
|
||||
path('', include(users_web_urls)), # Login, register, profile
|
||||
]
|
||||
|
||||
# Serve media files in development
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for config project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
@@ -0,0 +1,37 @@
|
||||
# KeepItGoing - Development Docker Compose Configuration
|
||||
# Usage: docker-compose -f docker-compose.dev.yml up -d
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data_dev:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_DB: keepitgoing
|
||||
POSTGRES_USER: keepitgoing
|
||||
POSTGRES_PASSWORD: keepitgoing
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U keepitgoing"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data_dev:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres_data_dev:
|
||||
redis_data_dev:
|
||||
@@ -0,0 +1,94 @@
|
||||
# KeepItGoing - Self-Hosted Docker Compose Configuration
|
||||
# Usage: docker-compose up -d
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_DB: keepitgoing
|
||||
POSTGRES_USER: keepitgoing
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-keepitgoing}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U keepitgoing"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
DJANGO_ENV: selfhosted
|
||||
SECRET_KEY: ${SECRET_KEY:-change-this-in-production}
|
||||
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1}
|
||||
POSTGRES_DB: keepitgoing
|
||||
POSTGRES_USER: keepitgoing
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-keepitgoing}
|
||||
POSTGRES_HOST: db
|
||||
POSTGRES_PORT: 5432
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- static_files:/app/staticfiles
|
||||
- media_files:/app/media
|
||||
|
||||
celery:
|
||||
build: .
|
||||
command: celery -A config worker -l info
|
||||
environment:
|
||||
DJANGO_ENV: selfhosted
|
||||
SECRET_KEY: ${SECRET_KEY:-change-this-in-production}
|
||||
POSTGRES_DB: keepitgoing
|
||||
POSTGRES_USER: keepitgoing
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-keepitgoing}
|
||||
POSTGRES_HOST: db
|
||||
POSTGRES_PORT: 5432
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
celery-beat:
|
||||
build: .
|
||||
command: celery -A config beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler
|
||||
environment:
|
||||
DJANGO_ENV: selfhosted
|
||||
SECRET_KEY: ${SECRET_KEY:-change-this-in-production}
|
||||
POSTGRES_DB: keepitgoing
|
||||
POSTGRES_USER: keepitgoing
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-keepitgoing}
|
||||
POSTGRES_HOST: db
|
||||
POSTGRES_PORT: 5432
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
static_files:
|
||||
media_files:
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Notification admin configuration.
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from .models import Notification, ScheduledReminder
|
||||
|
||||
|
||||
@admin.register(Notification)
|
||||
class NotificationAdmin(admin.ModelAdmin):
|
||||
"""Admin for Notification."""
|
||||
|
||||
list_display = ['title', 'user', 'notification_type', 'is_read', 'created_at']
|
||||
list_filter = ['notification_type', 'is_read', 'created_at']
|
||||
search_fields = ['title', 'message', 'user__email']
|
||||
ordering = ['-created_at']
|
||||
raw_id_fields = ['user', 'task']
|
||||
|
||||
|
||||
@admin.register(ScheduledReminder)
|
||||
class ScheduledReminderAdmin(admin.ModelAdmin):
|
||||
"""Admin for ScheduledReminder."""
|
||||
|
||||
list_display = ['task', 'remind_at', 'is_sent', 'sent_at', 'created_at']
|
||||
list_filter = ['is_sent', 'remind_at']
|
||||
search_fields = ['task__title']
|
||||
ordering = ['remind_at']
|
||||
raw_id_fields = ['task']
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NotificationsConfig(AppConfig):
|
||||
name = 'notifications'
|
||||
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-10 00:05
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Notification',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('notification_type', models.CharField(choices=[('reminder', 'Task Reminder'), ('due_soon', 'Due Soon'), ('overdue', 'Overdue'), ('shared', 'Task Shared'), ('comment', 'Comment')], max_length=20)),
|
||||
('title', models.CharField(max_length=255)),
|
||||
('message', models.TextField()),
|
||||
('is_read', models.BooleanField(default=False)),
|
||||
('read_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'notifications',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ScheduledReminder',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('remind_at', models.DateTimeField()),
|
||||
('is_sent', models.BooleanField(default=False)),
|
||||
('sent_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'scheduled_reminders',
|
||||
'ordering': ['remind_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-10 00:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('notifications', '0001_initial'),
|
||||
('tasks', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='notification',
|
||||
name='task',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='tasks.task'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-10 00:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('notifications', '0002_initial'),
|
||||
('tasks', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='notification',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='scheduledreminder',
|
||||
name='task',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_reminders', to='tasks.task'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Notification models for KeepItGoing.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Notification(models.Model):
|
||||
"""
|
||||
Stores notifications for users.
|
||||
"""
|
||||
NOTIFICATION_TYPES = [
|
||||
('reminder', 'Task Reminder'),
|
||||
('due_soon', 'Due Soon'),
|
||||
('overdue', 'Overdue'),
|
||||
('shared', 'Task Shared'),
|
||||
('comment', 'Comment'),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='notifications'
|
||||
)
|
||||
notification_type = models.CharField(max_length=20, choices=NOTIFICATION_TYPES)
|
||||
title = models.CharField(max_length=255)
|
||||
message = models.TextField()
|
||||
task = models.ForeignKey(
|
||||
'tasks.Task',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='notifications'
|
||||
)
|
||||
is_read = models.BooleanField(default=False)
|
||||
read_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'notifications'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.notification_type}: {self.title}"
|
||||
|
||||
|
||||
class ScheduledReminder(models.Model):
|
||||
"""
|
||||
Tracks scheduled reminders for tasks.
|
||||
"""
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
task = models.ForeignKey(
|
||||
'tasks.Task',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='scheduled_reminders'
|
||||
)
|
||||
remind_at = models.DateTimeField()
|
||||
is_sent = models.BooleanField(default=False)
|
||||
sent_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'scheduled_reminders'
|
||||
ordering = ['remind_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"Reminder for {self.task.title} at {self.remind_at}"
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Notification serializers for KeepItGoing API.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from .models import Notification, ScheduledReminder
|
||||
|
||||
|
||||
class NotificationSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for notifications."""
|
||||
|
||||
task_title = serializers.CharField(source='task.title', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Notification
|
||||
fields = [
|
||||
'id', 'notification_type', 'title', 'message', 'task',
|
||||
'task_title', 'is_read', 'read_at', 'created_at'
|
||||
]
|
||||
read_only_fields = ['id', 'created_at']
|
||||
|
||||
|
||||
class ScheduledReminderSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for scheduled reminders."""
|
||||
|
||||
class Meta:
|
||||
model = ScheduledReminder
|
||||
fields = ['id', 'task', 'remind_at', 'is_sent', 'sent_at', 'created_at']
|
||||
read_only_fields = ['id', 'is_sent', 'sent_at', 'created_at']
|
||||
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
Celery tasks for notifications.
|
||||
"""
|
||||
|
||||
from celery import shared_task
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_due_reminders():
|
||||
"""
|
||||
Check for tasks due soon and send reminders.
|
||||
Runs every 5 minutes via Celery Beat.
|
||||
"""
|
||||
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 users.models import DeviceToken
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
except User.DoesNotExist:
|
||||
return
|
||||
|
||||
if not user.push_notifications:
|
||||
return
|
||||
|
||||
tokens = DeviceToken.objects.filter(user=user, is_active=True)
|
||||
|
||||
for token in tokens:
|
||||
if token.platform == 'android':
|
||||
send_fcm_notification.delay(token.token, title, body, data)
|
||||
elif token.platform == 'web':
|
||||
send_web_push_notification.delay(token.token, title, body, data)
|
||||
elif token.platform == 'desktop':
|
||||
# Desktop notifications are handled via WebSocket
|
||||
pass
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_fcm_notification(token, title, body, data=None):
|
||||
"""Send FCM notification to Android device."""
|
||||
from django.conf import settings
|
||||
|
||||
# Only send if FCM is configured
|
||||
if not getattr(settings, 'FCM_CREDENTIALS_PATH', None):
|
||||
return
|
||||
|
||||
try:
|
||||
import firebase_admin
|
||||
from firebase_admin import credentials, messaging
|
||||
|
||||
# Initialize Firebase if not already done
|
||||
if not firebase_admin._apps:
|
||||
cred = credentials.Certificate(settings.FCM_CREDENTIALS_PATH)
|
||||
firebase_admin.initialize_app(cred)
|
||||
|
||||
message = messaging.Message(
|
||||
notification=messaging.Notification(
|
||||
title=title,
|
||||
body=body,
|
||||
),
|
||||
data=data or {},
|
||||
token=token,
|
||||
)
|
||||
|
||||
messaging.send(message)
|
||||
|
||||
except Exception as e:
|
||||
# Log error but don't fail
|
||||
print(f"FCM notification failed: {e}")
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_web_push_notification(subscription_info, title, body, data=None):
|
||||
"""Send Web Push notification."""
|
||||
from django.conf import settings
|
||||
|
||||
vapid_private_key = getattr(settings, 'VAPID_PRIVATE_KEY', None)
|
||||
vapid_email = getattr(settings, 'VAPID_ADMIN_EMAIL', None)
|
||||
|
||||
if not vapid_private_key or not vapid_email:
|
||||
return
|
||||
|
||||
try:
|
||||
from pywebpush import webpush, WebPushException
|
||||
import json
|
||||
|
||||
webpush(
|
||||
subscription_info=json.loads(subscription_info),
|
||||
data=json.dumps({
|
||||
'title': title,
|
||||
'body': body,
|
||||
'data': data or {}
|
||||
}),
|
||||
vapid_private_key=vapid_private_key,
|
||||
vapid_claims={'sub': f'mailto:{vapid_email}'}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(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
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Notification URLs for KeepItGoing.
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
# API URLs (to be included under /api/notifications/)
|
||||
api_urlpatterns = [
|
||||
path('', views.NotificationListAPIView.as_view(), name='api-notification-list'),
|
||||
path('<uuid:notification_id>/read/', views.mark_read, name='api-notification-read'),
|
||||
path('read-all/', views.mark_all_read, name='api-notification-read-all'),
|
||||
path('unread-count/', views.unread_count, name='api-notification-unread-count'),
|
||||
path('<uuid:notification_id>/', views.delete_notification, name='api-notification-delete'),
|
||||
]
|
||||
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Notification views for KeepItGoing.
|
||||
"""
|
||||
|
||||
from django.utils import timezone
|
||||
from rest_framework import generics, permissions, status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .models import Notification
|
||||
from .serializers import NotificationSerializer
|
||||
|
||||
|
||||
class NotificationListAPIView(generics.ListAPIView):
|
||||
"""API endpoint for listing notifications."""
|
||||
|
||||
serializer_class = NotificationSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = Notification.objects.filter(user=self.request.user)
|
||||
|
||||
# Filter by read status
|
||||
is_read = self.request.query_params.get('is_read')
|
||||
if is_read is not None:
|
||||
queryset = queryset.filter(is_read=is_read.lower() == 'true')
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([permissions.IsAuthenticated])
|
||||
def mark_read(request, notification_id):
|
||||
"""Mark a notification as read."""
|
||||
try:
|
||||
notification = Notification.objects.get(
|
||||
id=notification_id,
|
||||
user=request.user
|
||||
)
|
||||
except Notification.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'Notification not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
notification.is_read = True
|
||||
notification.read_at = timezone.now()
|
||||
notification.save()
|
||||
|
||||
return Response(NotificationSerializer(notification).data)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([permissions.IsAuthenticated])
|
||||
def mark_all_read(request):
|
||||
"""Mark all notifications as read."""
|
||||
Notification.objects.filter(
|
||||
user=request.user,
|
||||
is_read=False
|
||||
).update(
|
||||
is_read=True,
|
||||
read_at=timezone.now()
|
||||
)
|
||||
|
||||
return Response({'status': 'All notifications marked as read'})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([permissions.IsAuthenticated])
|
||||
def unread_count(request):
|
||||
"""Get count of unread notifications."""
|
||||
count = Notification.objects.filter(
|
||||
user=request.user,
|
||||
is_read=False
|
||||
).count()
|
||||
|
||||
return Response({'unread_count': count})
|
||||
|
||||
|
||||
@api_view(['DELETE'])
|
||||
@permission_classes([permissions.IsAuthenticated])
|
||||
def delete_notification(request, notification_id):
|
||||
"""Delete a notification."""
|
||||
try:
|
||||
notification = Notification.objects.get(
|
||||
id=notification_id,
|
||||
user=request.user
|
||||
)
|
||||
except Notification.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'Notification not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
notification.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,30 @@
|
||||
# Django core
|
||||
Django>=5.0,<6.0
|
||||
django-environ>=0.11.0
|
||||
django-cors-headers>=4.3.0
|
||||
django-filter>=24.0
|
||||
|
||||
# Django REST Framework
|
||||
djangorestframework>=3.14.0
|
||||
djangorestframework-simplejwt>=5.3.0
|
||||
|
||||
# Database
|
||||
psycopg2-binary>=2.9.9
|
||||
dj-database-url>=2.1.0
|
||||
|
||||
# Task queue
|
||||
celery>=5.3.0
|
||||
redis>=5.0.0
|
||||
django-celery-beat>=2.5.0
|
||||
|
||||
# Push notifications
|
||||
firebase-admin>=6.4.0
|
||||
pywebpush>=1.14.0
|
||||
|
||||
# Utilities
|
||||
python-dateutil>=2.8.2
|
||||
Pillow>=10.0.0
|
||||
|
||||
# Production
|
||||
gunicorn>=21.0.0
|
||||
whitenoise>=6.6.0
|
||||
+1028
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,549 @@
|
||||
/* KeepItGoing - Main Stylesheet */
|
||||
|
||||
/* CSS Variables */
|
||||
:root {
|
||||
--primary-color: #3B82F6;
|
||||
--primary-hover: #2563EB;
|
||||
--secondary-color: #6B7280;
|
||||
--success-color: #10B981;
|
||||
--warning-color: #F59E0B;
|
||||
--danger-color: #EF4444;
|
||||
--background-color: #F3F4F6;
|
||||
--card-background: #FFFFFF;
|
||||
--text-color: #1F2937;
|
||||
--text-muted: #6B7280;
|
||||
--border-color: #E5E7EB;
|
||||
--border-radius: 8px;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Reset */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Base */
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
.navbar {
|
||||
background-color: var(--card-background);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.nav-brand a {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-links a:hover,
|
||||
.nav-links a.active {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-user {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background-color: var(--card-background);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--border-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #DC2626;
|
||||
}
|
||||
|
||||
.btn-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: #FEE2E2;
|
||||
color: #991B1B;
|
||||
border: 1px solid #FECACA;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: #D1FAE5;
|
||||
color: #065F46;
|
||||
border: 1px solid #A7F3D0;
|
||||
}
|
||||
|
||||
/* Auth Pages */
|
||||
.auth-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background-color: var(--card-background);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.auth-card h1 {
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Page Header */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* Task List */
|
||||
.task-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.section-overdue {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background-color: var(--card-background);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
border-left: 4px solid transparent;
|
||||
}
|
||||
|
||||
.task-item.priority-urgent {
|
||||
border-left-color: var(--danger-color);
|
||||
}
|
||||
|
||||
.task-item.priority-high {
|
||||
border-left-color: var(--warning-color);
|
||||
}
|
||||
|
||||
.task-item.priority-medium {
|
||||
border-left-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.task-item.priority-low {
|
||||
border-left-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.task-item.task-overdue {
|
||||
background-color: #FEF2F2;
|
||||
}
|
||||
|
||||
.task-checkbox {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-checkbox.checked {
|
||||
background-color: var(--success-color);
|
||||
border-color: var(--success-color);
|
||||
}
|
||||
|
||||
.task-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.task-title:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.task-due {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-due.overdue {
|
||||
color: var(--danger-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-group,
|
||||
.task-tag {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.task-priority {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.priority-urgent {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.priority-high {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.priority-medium {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.priority-low {
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
/* Quick Add */
|
||||
.quick-add-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.quick-add-form input[type="text"] {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.quick-add-form input[type="date"],
|
||||
.quick-add-form select {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Timer */
|
||||
.timer-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.timer-display {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.filter-form {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-form select {
|
||||
width: auto;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* Groups */
|
||||
.group-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.group-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.group-description {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.group-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.group-actions {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Tags */
|
||||
.tag-select {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Time Entries */
|
||||
.time-entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.time-entry {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--background-color);
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.time-total {
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.inline-form {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.navbar {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
/**
|
||||
* KeepItGoing - Main Application JavaScript
|
||||
* Handles theme toggle, task selection, mobile menus, and AJAX operations
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initTheme();
|
||||
initTaskSelection();
|
||||
initTimerDisplays();
|
||||
});
|
||||
|
||||
/* ============================================
|
||||
Theme Toggle
|
||||
============================================ */
|
||||
|
||||
function initTheme() {
|
||||
// Check for saved theme preference or system preference
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
if (savedTheme) {
|
||||
setTheme(savedTheme);
|
||||
} else if (systemDark) {
|
||||
setTheme('dark');
|
||||
}
|
||||
|
||||
// Listen for system theme changes
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
setTheme(e.matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
setTheme(newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
|
||||
// Update theme toggle icon
|
||||
const lightIcon = document.getElementById('theme-icon-light');
|
||||
const darkIcon = document.getElementById('theme-icon-dark');
|
||||
|
||||
if (lightIcon && darkIcon) {
|
||||
if (theme === 'dark') {
|
||||
lightIcon.style.display = 'none';
|
||||
darkIcon.style.display = 'block';
|
||||
} else {
|
||||
lightIcon.style.display = 'block';
|
||||
darkIcon.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Task Selection
|
||||
============================================ */
|
||||
|
||||
function initTaskSelection() {
|
||||
// Add click handlers to task items
|
||||
const taskItems = document.querySelectorAll('.task-item');
|
||||
taskItems.forEach(item => {
|
||||
item.addEventListener('click', function(e) {
|
||||
// Don't select if clicking checkbox form
|
||||
if (e.target.closest('.task-toggle')) {
|
||||
return;
|
||||
}
|
||||
const taskId = this.dataset.taskId;
|
||||
if (taskId) {
|
||||
selectTask(taskId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function selectTask(taskId) {
|
||||
// Update URL without full page reload
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('selected', taskId);
|
||||
window.history.pushState({}, '', url);
|
||||
|
||||
// Update visual selection
|
||||
document.querySelectorAll('.task-item').forEach(item => {
|
||||
item.classList.remove('selected');
|
||||
});
|
||||
const selectedItem = document.querySelector(`.task-item[data-task-id="${taskId}"]`);
|
||||
if (selectedItem) {
|
||||
selectedItem.classList.add('selected');
|
||||
}
|
||||
|
||||
// Load task detail via AJAX
|
||||
loadTaskDetail(taskId);
|
||||
}
|
||||
|
||||
function loadTaskDetail(taskId) {
|
||||
const detailPane = document.getElementById('detail-pane');
|
||||
const appLayout = document.getElementById('app');
|
||||
|
||||
if (!detailPane) return;
|
||||
|
||||
// Show loading state
|
||||
detailPane.innerHTML = '<div class="empty-state"><p>Loading...</p></div>';
|
||||
detailPane.classList.remove('hidden');
|
||||
if (appLayout) {
|
||||
appLayout.classList.remove('detail-closed');
|
||||
}
|
||||
|
||||
// On mobile, show detail pane
|
||||
if (window.innerWidth <= 1024) {
|
||||
detailPane.classList.add('open');
|
||||
document.getElementById('mobile-overlay').classList.add('visible');
|
||||
}
|
||||
|
||||
// Fetch task detail HTML
|
||||
fetch(`/tasks/${taskId}/detail-partial/`)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Failed to load task');
|
||||
return response.text();
|
||||
})
|
||||
.then(html => {
|
||||
detailPane.innerHTML = html;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading task detail:', error);
|
||||
detailPane.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p class="text-danger">Failed to load task details</p>
|
||||
<p class="text-muted">Please refresh the page</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
const detailPane = document.getElementById('detail-pane');
|
||||
const appLayout = document.getElementById('app');
|
||||
|
||||
if (detailPane) {
|
||||
detailPane.classList.add('hidden');
|
||||
detailPane.classList.remove('open');
|
||||
}
|
||||
if (appLayout) {
|
||||
appLayout.classList.add('detail-closed');
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
document.querySelectorAll('.task-item').forEach(item => {
|
||||
item.classList.remove('selected');
|
||||
});
|
||||
|
||||
// Update URL
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.delete('selected');
|
||||
window.history.pushState({}, '', url);
|
||||
|
||||
// Close mobile overlay
|
||||
closeMobileMenus();
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Mobile Navigation
|
||||
============================================ */
|
||||
|
||||
function toggleSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const overlay = document.getElementById('mobile-overlay');
|
||||
|
||||
if (sidebar) {
|
||||
sidebar.classList.toggle('open');
|
||||
}
|
||||
if (overlay) {
|
||||
overlay.classList.toggle('visible', sidebar.classList.contains('open'));
|
||||
}
|
||||
}
|
||||
|
||||
function closeMobileMenus() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const detailPane = document.getElementById('detail-pane');
|
||||
const overlay = document.getElementById('mobile-overlay');
|
||||
|
||||
if (sidebar) {
|
||||
sidebar.classList.remove('open');
|
||||
}
|
||||
if (detailPane) {
|
||||
detailPane.classList.remove('open');
|
||||
}
|
||||
if (overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Timer Displays
|
||||
============================================ */
|
||||
|
||||
function initTimerDisplays() {
|
||||
// Handle running timer in header/dashboard
|
||||
const runningTimer = document.getElementById('running-timer');
|
||||
if (runningTimer) {
|
||||
const startedAt = new Date(runningTimer.dataset.started);
|
||||
updateTimerDisplay(runningTimer, startedAt);
|
||||
setInterval(() => updateTimerDisplay(runningTimer, startedAt), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function updateTimerDisplay(element, startedAt) {
|
||||
const now = new Date();
|
||||
const diff = Math.floor((now - startedAt) / 1000);
|
||||
const hours = Math.floor(diff / 3600).toString().padStart(2, '0');
|
||||
const minutes = Math.floor((diff % 3600) / 60).toString().padStart(2, '0');
|
||||
const seconds = (diff % 60).toString().padStart(2, '0');
|
||||
element.textContent = `${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Utility Functions
|
||||
============================================ */
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
} else if (seconds < 3600) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
return `${mins}m`;
|
||||
} else {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const dateOnly = new Date(date);
|
||||
dateOnly.setHours(0, 0, 0, 0);
|
||||
|
||||
if (dateOnly.getTime() === today.getTime()) {
|
||||
return 'Today';
|
||||
} else if (dateOnly.getTime() === tomorrow.getTime()) {
|
||||
return 'Tomorrow';
|
||||
} else if (dateOnly.getTime() === yesterday.getTime()) {
|
||||
return 'Yesterday';
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Handle Browser Back/Forward
|
||||
============================================ */
|
||||
|
||||
window.addEventListener('popstate', function(e) {
|
||||
const url = new URL(window.location);
|
||||
const selectedTaskId = url.searchParams.get('selected');
|
||||
|
||||
if (selectedTaskId) {
|
||||
// Show the selected task
|
||||
document.querySelectorAll('.task-item').forEach(item => {
|
||||
item.classList.remove('selected');
|
||||
});
|
||||
const selectedItem = document.querySelector(`.task-item[data-task-id="${selectedTaskId}"]`);
|
||||
if (selectedItem) {
|
||||
selectedItem.classList.add('selected');
|
||||
}
|
||||
loadTaskDetail(selectedTaskId);
|
||||
} else {
|
||||
// Close detail panel
|
||||
closeDetail();
|
||||
}
|
||||
});
|
||||
|
||||
/* ============================================
|
||||
Group Modal
|
||||
============================================ */
|
||||
|
||||
let currentGroupId = null;
|
||||
let selectedColor = '#3b82f6';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initGroupModal();
|
||||
});
|
||||
|
||||
function initGroupModal() {
|
||||
// Setup backdrop click to close
|
||||
const backdrop = document.getElementById('group-modal-backdrop');
|
||||
if (backdrop) {
|
||||
backdrop.addEventListener('click', function(e) {
|
||||
if (e.target === backdrop) {
|
||||
closeGroupModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Setup color preset buttons
|
||||
document.querySelectorAll('.color-preset').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
selectColor(this.dataset.color);
|
||||
});
|
||||
});
|
||||
|
||||
// Setup custom color picker
|
||||
const colorPicker = document.getElementById('group-color');
|
||||
if (colorPicker) {
|
||||
colorPicker.addEventListener('input', function() {
|
||||
selectColor(this.value, true);
|
||||
});
|
||||
}
|
||||
|
||||
// Setup name field for preview
|
||||
const nameField = document.getElementById('group-name');
|
||||
if (nameField) {
|
||||
nameField.addEventListener('input', updateGroupPreview);
|
||||
}
|
||||
|
||||
// Setup form submission
|
||||
const form = document.getElementById('group-form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', handleGroupSubmit);
|
||||
}
|
||||
}
|
||||
|
||||
// Make openGroupModal globally available for onclick handlers
|
||||
window.openGroupModal = function(groupId = null, name = '', color = '#3b82f6') {
|
||||
console.log('openGroupModal called:', { groupId, name, color });
|
||||
|
||||
const backdrop = document.getElementById('group-modal-backdrop');
|
||||
const title = document.getElementById('group-modal-title');
|
||||
const nameField = document.getElementById('group-name');
|
||||
const deleteBtn = document.getElementById('group-delete-btn');
|
||||
const submitBtn = document.getElementById('group-submit-btn');
|
||||
const groupIdField = document.getElementById('group-id');
|
||||
|
||||
if (!backdrop) {
|
||||
console.error('Modal backdrop not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
currentGroupId = groupId;
|
||||
|
||||
if (groupId) {
|
||||
// Edit mode
|
||||
if (title) title.textContent = 'Edit Group';
|
||||
if (nameField) nameField.value = name;
|
||||
if (deleteBtn) deleteBtn.style.display = 'inline-flex';
|
||||
if (submitBtn) submitBtn.textContent = 'Save';
|
||||
if (groupIdField) groupIdField.value = groupId;
|
||||
} else {
|
||||
// Create mode
|
||||
if (title) title.textContent = 'New Group';
|
||||
if (nameField) nameField.value = '';
|
||||
if (deleteBtn) deleteBtn.style.display = 'none';
|
||||
if (submitBtn) submitBtn.textContent = 'Create';
|
||||
if (groupIdField) groupIdField.value = '';
|
||||
color = '#3b82f6';
|
||||
}
|
||||
|
||||
selectColor(color);
|
||||
updateGroupPreview();
|
||||
|
||||
backdrop.classList.add('open');
|
||||
console.log('Modal should now be open');
|
||||
if (nameField) nameField.focus();
|
||||
};
|
||||
|
||||
// Make closeGroupModal globally available for onclick handlers
|
||||
window.closeGroupModal = function() {
|
||||
const backdrop = document.getElementById('group-modal-backdrop');
|
||||
if (backdrop) {
|
||||
backdrop.classList.remove('open');
|
||||
}
|
||||
currentGroupId = null;
|
||||
};
|
||||
|
||||
function selectColor(color, isCustom = false) {
|
||||
selectedColor = color;
|
||||
|
||||
// Update preset button selection
|
||||
document.querySelectorAll('.color-preset').forEach(btn => {
|
||||
btn.classList.toggle('selected', btn.dataset.color.toLowerCase() === color.toLowerCase());
|
||||
});
|
||||
|
||||
// Update custom color picker if not already from custom
|
||||
if (!isCustom) {
|
||||
const colorPicker = document.getElementById('group-color');
|
||||
if (colorPicker) {
|
||||
colorPicker.value = color;
|
||||
}
|
||||
}
|
||||
|
||||
// Update hidden field
|
||||
const hiddenField = document.getElementById('group-color-hidden');
|
||||
if (hiddenField) {
|
||||
hiddenField.value = color;
|
||||
}
|
||||
|
||||
// Update preview
|
||||
updateGroupPreview();
|
||||
}
|
||||
|
||||
function updateGroupPreview() {
|
||||
const nameField = document.getElementById('group-name');
|
||||
const previewDot = document.getElementById('group-preview-dot');
|
||||
const previewName = document.getElementById('group-preview-name');
|
||||
|
||||
if (previewDot) {
|
||||
previewDot.style.backgroundColor = selectedColor;
|
||||
}
|
||||
if (previewName && nameField) {
|
||||
previewName.textContent = nameField.value || 'Group Preview';
|
||||
previewName.style.color = nameField.value ? 'var(--text-primary)' : 'var(--text-muted)';
|
||||
}
|
||||
}
|
||||
|
||||
function handleGroupSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const nameField = document.getElementById('group-name');
|
||||
const name = nameField.value.trim();
|
||||
|
||||
if (!name) {
|
||||
nameField.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||
const formData = new FormData();
|
||||
formData.append('name', name);
|
||||
formData.append('color', selectedColor);
|
||||
formData.append('csrfmiddlewaretoken', csrfToken);
|
||||
|
||||
let url, method;
|
||||
if (currentGroupId) {
|
||||
// Update existing group
|
||||
url = `/groups/${currentGroupId}/`;
|
||||
formData.append('next', window.location.pathname + window.location.search);
|
||||
} else {
|
||||
// Create new group
|
||||
url = '/groups/';
|
||||
formData.append('next', window.location.pathname + window.location.search);
|
||||
}
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.redirected) {
|
||||
window.location.href = response.url;
|
||||
} else if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
throw new Error('Failed to save group');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error saving group:', error);
|
||||
alert('Failed to save group. Please try again.');
|
||||
});
|
||||
}
|
||||
|
||||
// Make deleteGroup globally available for onclick handlers
|
||||
window.deleteGroup = function() {
|
||||
if (!currentGroupId) return;
|
||||
|
||||
if (!confirm('Are you sure you want to delete this group?\n\nTasks in this group will not be deleted.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||
const formData = new FormData();
|
||||
formData.append('csrfmiddlewaretoken', csrfToken);
|
||||
formData.append('next', window.location.pathname + window.location.search);
|
||||
|
||||
fetch(`/groups/${currentGroupId}/delete/`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.redirected) {
|
||||
window.location.href = response.url;
|
||||
} else if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
throw new Error('Failed to delete group');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error deleting group:', error);
|
||||
alert('Failed to delete group. Please try again.');
|
||||
});
|
||||
};
|
||||
|
||||
/* ============================================
|
||||
Tag Modal
|
||||
============================================ */
|
||||
|
||||
let currentTagId = null;
|
||||
let selectedTagColor = '#3b82f6';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initTagModal();
|
||||
});
|
||||
|
||||
function initTagModal() {
|
||||
// Setup backdrop click to close
|
||||
const backdrop = document.getElementById('tag-modal-backdrop');
|
||||
if (backdrop) {
|
||||
backdrop.addEventListener('click', function(e) {
|
||||
if (e.target === backdrop) {
|
||||
closeTagModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Setup color preset buttons
|
||||
document.querySelectorAll('.color-preset').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
selectTagColor(this.dataset.color);
|
||||
});
|
||||
});
|
||||
|
||||
// Setup custom color picker
|
||||
const colorPicker = document.getElementById('tag-color');
|
||||
if (colorPicker) {
|
||||
colorPicker.addEventListener('input', function() {
|
||||
selectTagColor(this.value, true);
|
||||
});
|
||||
}
|
||||
|
||||
// Setup form submission
|
||||
const form = document.getElementById('tag-form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', handleTagSubmit);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTagSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const nameField = document.getElementById('tag-name');
|
||||
const name = nameField.value.trim();
|
||||
|
||||
if (!name) {
|
||||
nameField.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||
const formData = new FormData();
|
||||
formData.append('name', name);
|
||||
formData.append('color', selectedTagColor);
|
||||
formData.append('csrfmiddlewaretoken', csrfToken);
|
||||
|
||||
let url;
|
||||
if (currentTagId) {
|
||||
// Update existing tag
|
||||
url = `/tags/${currentTagId}/`;
|
||||
formData.append('next', window.location.pathname + window.location.search);
|
||||
} else {
|
||||
// Create new tag
|
||||
url = '/tags/';
|
||||
formData.append('next', window.location.pathname + window.location.search);
|
||||
}
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.redirected) {
|
||||
window.location.href = response.url;
|
||||
} else if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
throw new Error('Failed to save tag');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error saving tag:', error);
|
||||
alert('Failed to save tag. Please try again.');
|
||||
});
|
||||
}
|
||||
|
||||
// Make openTagModal globally available for onclick handlers
|
||||
window.openTagModal = function(tagId = null, name = '', color = '#3b82f6') {
|
||||
console.log('openTagModal called:', { tagId, name, color });
|
||||
|
||||
const backdrop = document.getElementById('tag-modal-backdrop');
|
||||
const title = document.getElementById('tag-modal-title');
|
||||
const nameField = document.getElementById('tag-name');
|
||||
const deleteBtn = document.getElementById('tag-delete-btn');
|
||||
const submitBtn = document.getElementById('tag-submit-btn');
|
||||
const tagIdField = document.getElementById('tag-id');
|
||||
|
||||
if (!backdrop) {
|
||||
console.error('Tag modal backdrop not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
currentTagId = tagId;
|
||||
|
||||
if (tagId) {
|
||||
// Edit mode
|
||||
if (title) title.textContent = 'Edit Tag';
|
||||
if (nameField) nameField.value = name;
|
||||
if (deleteBtn) deleteBtn.style.display = 'inline-flex';
|
||||
if (submitBtn) submitBtn.textContent = 'Save';
|
||||
if (tagIdField) tagIdField.value = tagId;
|
||||
} else {
|
||||
// Create mode
|
||||
if (title) title.textContent = 'New Tag';
|
||||
if (nameField) nameField.value = '';
|
||||
if (deleteBtn) deleteBtn.style.display = 'none';
|
||||
if (submitBtn) submitBtn.textContent = 'Create';
|
||||
if (tagIdField) tagIdField.value = '';
|
||||
color = '#3b82f6';
|
||||
}
|
||||
|
||||
selectTagColor(color);
|
||||
backdrop.classList.add('open');
|
||||
if (nameField) nameField.focus();
|
||||
};
|
||||
|
||||
// Make closeTagModal globally available for onclick handlers
|
||||
window.closeTagModal = function() {
|
||||
const backdrop = document.getElementById('tag-modal-backdrop');
|
||||
if (backdrop) {
|
||||
backdrop.classList.remove('open');
|
||||
}
|
||||
currentTagId = null;
|
||||
};
|
||||
|
||||
function selectTagColor(color) {
|
||||
selectedTagColor = color;
|
||||
|
||||
// Update preset button selection
|
||||
document.querySelectorAll('.color-preset').forEach(btn => {
|
||||
btn.classList.toggle('selected', btn.dataset.color.toLowerCase() === color.toLowerCase());
|
||||
});
|
||||
|
||||
// Update custom color picker
|
||||
const colorPicker = document.getElementById('tag-color');
|
||||
if (colorPicker) {
|
||||
colorPicker.value = color;
|
||||
}
|
||||
|
||||
// Update hidden field
|
||||
const hiddenField = document.getElementById('tag-color-hidden');
|
||||
if (hiddenField) {
|
||||
hiddenField.value = color;
|
||||
}
|
||||
}
|
||||
|
||||
// Make deleteTag globally available for onclick handlers
|
||||
window.deleteTag = function() {
|
||||
if (!currentTagId) return;
|
||||
|
||||
if (!confirm('Are you sure you want to delete this tag?\n\nTasks with this tag will not be deleted.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||
const formData = new FormData();
|
||||
formData.append('csrfmiddlewaretoken', csrfToken);
|
||||
formData.append('next', window.location.pathname + window.location.search);
|
||||
|
||||
fetch(`/tags/${currentTagId}/delete/`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.redirected) {
|
||||
window.location.href = response.url;
|
||||
} else if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
throw new Error('Failed to delete tag');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error deleting tag:', error);
|
||||
alert('Failed to delete tag. Please try again.');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* KeepItGoing - Main JavaScript
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize components
|
||||
initTimerDisplay();
|
||||
initTaskToggle();
|
||||
initConfirmDialogs();
|
||||
});
|
||||
|
||||
/**
|
||||
* Update timer display
|
||||
*/
|
||||
function initTimerDisplay() {
|
||||
const timerDisplay = document.querySelector('.timer-display');
|
||||
if (!timerDisplay) return;
|
||||
|
||||
const startedAt = new Date(timerDisplay.dataset.started);
|
||||
|
||||
function updateTimer() {
|
||||
const now = new Date();
|
||||
const diff = Math.floor((now - startedAt) / 1000);
|
||||
const hours = Math.floor(diff / 3600).toString().padStart(2, '0');
|
||||
const minutes = Math.floor((diff % 3600) / 60).toString().padStart(2, '0');
|
||||
const seconds = (diff % 60).toString().padStart(2, '0');
|
||||
timerDisplay.textContent = `${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
updateTimer();
|
||||
setInterval(updateTimer, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle task toggle without page reload (optional enhancement)
|
||||
*/
|
||||
function initTaskToggle() {
|
||||
// For now, we let the form submit normally
|
||||
// This could be enhanced with AJAX in the future
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize confirmation dialogs
|
||||
*/
|
||||
function initConfirmDialogs() {
|
||||
const deleteButtons = document.querySelectorAll('[data-confirm]');
|
||||
deleteButtons.forEach(button => {
|
||||
button.addEventListener('click', function(e) {
|
||||
const message = this.dataset.confirm || 'Are you sure?';
|
||||
if (!confirm(message)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in seconds to human-readable string
|
||||
*/
|
||||
function formatDuration(seconds) {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
} else if (seconds < 3600) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
return `${mins}m`;
|
||||
} else {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date to relative string (today, tomorrow, etc.)
|
||||
*/
|
||||
function formatRelativeDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const dateOnly = new Date(date);
|
||||
dateOnly.setHours(0, 0, 0, 0);
|
||||
|
||||
if (dateOnly.getTime() === today.getTime()) {
|
||||
return 'Today';
|
||||
} else if (dateOnly.getTime() === tomorrow.getTime()) {
|
||||
return 'Tomorrow';
|
||||
} else if (dateOnly.getTime() === yesterday.getTime()) {
|
||||
return 'Yesterday';
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SyncConfig(AppConfig):
|
||||
name = 'sync'
|
||||
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-10 00:05
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SyncConflict',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('entity_type', models.CharField(max_length=50)),
|
||||
('entity_id', models.UUIDField()),
|
||||
('local_data', models.JSONField()),
|
||||
('server_data', models.JSONField()),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('resolved_local', 'Resolved (Local)'), ('resolved_server', 'Resolved (Server)'), ('resolved_merged', 'Resolved (Merged)')], default='pending', max_length=20)),
|
||||
('resolved_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'sync_conflicts',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SyncLog',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('device_id', models.CharField(max_length=255)),
|
||||
('last_sync_at', models.DateTimeField()),
|
||||
('sync_token', models.CharField(max_length=255, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'sync_logs',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-10 00:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('sync', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='syncconflict',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sync_conflicts', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='synclog',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sync_logs', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='synclog',
|
||||
unique_together={('user', 'device_id')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Sync models for KeepItGoing.
|
||||
|
||||
Tracks sync state for offline clients.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class SyncLog(models.Model):
|
||||
"""
|
||||
Tracks sync operations for each user/device.
|
||||
"""
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='sync_logs'
|
||||
)
|
||||
device_id = models.CharField(max_length=255)
|
||||
last_sync_at = models.DateTimeField()
|
||||
sync_token = models.CharField(max_length=255, unique=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'sync_logs'
|
||||
unique_together = ['user', 'device_id']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email} - {self.device_id}"
|
||||
|
||||
|
||||
class SyncConflict(models.Model):
|
||||
"""
|
||||
Records sync conflicts for manual resolution.
|
||||
"""
|
||||
CONFLICT_STATUS = [
|
||||
('pending', 'Pending'),
|
||||
('resolved_local', 'Resolved (Local)'),
|
||||
('resolved_server', 'Resolved (Server)'),
|
||||
('resolved_merged', 'Resolved (Merged)'),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='sync_conflicts'
|
||||
)
|
||||
entity_type = models.CharField(max_length=50) # task, group, tag, etc.
|
||||
entity_id = models.UUIDField()
|
||||
local_data = models.JSONField()
|
||||
server_data = models.JSONField()
|
||||
status = models.CharField(max_length=20, choices=CONFLICT_STATUS, default='pending')
|
||||
resolved_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'sync_conflicts'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.entity_type} conflict for {self.user.email}"
|
||||
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Sync serializers for KeepItGoing API.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from .models import SyncLog, SyncConflict
|
||||
|
||||
|
||||
class SyncLogSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for sync logs."""
|
||||
|
||||
class Meta:
|
||||
model = SyncLog
|
||||
fields = ['id', 'device_id', 'last_sync_at', 'sync_token', 'created_at']
|
||||
read_only_fields = ['id', 'created_at']
|
||||
|
||||
|
||||
class SyncConflictSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for sync conflicts."""
|
||||
|
||||
class Meta:
|
||||
model = SyncConflict
|
||||
fields = [
|
||||
'id', 'entity_type', 'entity_id', 'local_data', 'server_data',
|
||||
'status', 'resolved_at', 'created_at'
|
||||
]
|
||||
read_only_fields = ['id', 'created_at']
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Sync URLs for KeepItGoing.
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
# API URLs (to be included under /api/sync/)
|
||||
api_urlpatterns = [
|
||||
path('', views.sync, name='api-sync'),
|
||||
path('conflicts/', views.get_conflicts, name='api-sync-conflicts'),
|
||||
path('conflicts/<uuid:conflict_id>/resolve/', views.resolve_conflict, name='api-sync-resolve'),
|
||||
]
|
||||
+464
@@ -0,0 +1,464 @@
|
||||
"""
|
||||
Sync views for KeepItGoing.
|
||||
|
||||
Handles data synchronization between clients and server.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from tasks.models import Task, Tag, TimeEntry
|
||||
from tasks.serializers import (
|
||||
TaskSerializer,
|
||||
TagSerializer,
|
||||
TimeEntrySerializer,
|
||||
TaskSyncSerializer,
|
||||
TagSyncSerializer,
|
||||
TimeEntrySyncSerializer,
|
||||
)
|
||||
from .models import SyncLog, SyncConflict
|
||||
from .serializers import SyncConflictSerializer
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def sync(request):
|
||||
"""
|
||||
Main sync endpoint.
|
||||
|
||||
Accepts client changes and returns server changes since last sync.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"device_id": "unique-device-id",
|
||||
"last_sync_token": "previous-sync-token-or-null",
|
||||
"changes": {
|
||||
"tasks": [...],
|
||||
"tags": [...],
|
||||
"time_entries": [...]
|
||||
}
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"sync_token": "new-sync-token",
|
||||
"server_changes": {
|
||||
"tasks": [...],
|
||||
"tags": [...],
|
||||
"time_entries": [...]
|
||||
},
|
||||
"conflicts": [...]
|
||||
}
|
||||
"""
|
||||
user = request.user
|
||||
device_id = request.data.get('device_id')
|
||||
last_sync_token = request.data.get('last_sync_token')
|
||||
changes = request.data.get('changes', {})
|
||||
|
||||
# Debug logging
|
||||
task_changes = changes.get('tasks', [])
|
||||
deleted_task_count = sum(1 for t in task_changes if t.get('is_deleted', False))
|
||||
print(f"[SERVER SYNC DEBUG] Received sync request with {len(task_changes)} tasks, {deleted_task_count} marked as deleted")
|
||||
|
||||
if not device_id:
|
||||
return Response(
|
||||
{'error': 'device_id is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Get last sync time
|
||||
last_sync_at = None
|
||||
if last_sync_token:
|
||||
try:
|
||||
sync_log = SyncLog.objects.get(sync_token=last_sync_token, user=user)
|
||||
last_sync_at = sync_log.last_sync_at
|
||||
except SyncLog.DoesNotExist:
|
||||
pass # Full sync
|
||||
|
||||
# Process incoming changes
|
||||
conflicts = []
|
||||
conflicts.extend(process_tag_changes(user, changes.get('tags', []), last_sync_at))
|
||||
conflicts.extend(process_task_changes(user, changes.get('tasks', []), last_sync_at))
|
||||
conflicts.extend(process_time_entry_changes(user, changes.get('time_entries', []), last_sync_at))
|
||||
|
||||
# Get server changes since last sync
|
||||
server_changes = get_server_changes(user, last_sync_at)
|
||||
|
||||
# Generate new sync token
|
||||
new_sync_token = str(uuid.uuid4())
|
||||
current_time = timezone.now()
|
||||
|
||||
# Update or create sync log
|
||||
SyncLog.objects.update_or_create(
|
||||
user=user,
|
||||
device_id=device_id,
|
||||
defaults={
|
||||
'last_sync_at': current_time,
|
||||
'sync_token': new_sync_token,
|
||||
}
|
||||
)
|
||||
|
||||
return Response({
|
||||
'sync_token': new_sync_token,
|
||||
'server_time': current_time.isoformat(),
|
||||
'server_changes': server_changes,
|
||||
'conflicts': SyncConflictSerializer(conflicts, many=True).data,
|
||||
})
|
||||
|
||||
|
||||
def process_task_changes(user, task_changes, last_sync_at):
|
||||
"""Process task changes from client."""
|
||||
conflicts = []
|
||||
|
||||
for task_data in task_changes:
|
||||
sync_id = task_data.get('sync_id')
|
||||
is_deleted = task_data.get('is_deleted', False)
|
||||
|
||||
# Debug logging
|
||||
if is_deleted:
|
||||
print(f"[SERVER SYNC DEBUG] Received deletion for task sync_id: {sync_id}")
|
||||
|
||||
try:
|
||||
existing = Task.all_objects.get(sync_id=sync_id, user=user)
|
||||
|
||||
if is_deleted:
|
||||
# Soft delete the task
|
||||
print(f"[SERVER SYNC DEBUG] Soft deleting task: {existing.title} (id: {existing.id})")
|
||||
existing.is_deleted = True
|
||||
existing.save()
|
||||
print(f"[SERVER SYNC DEBUG] Task soft deleted successfully")
|
||||
continue
|
||||
|
||||
# Check for conflict
|
||||
if last_sync_at and existing.updated_at > last_sync_at:
|
||||
# Server version was modified since last sync
|
||||
conflict = SyncConflict.objects.create(
|
||||
user=user,
|
||||
entity_type='task',
|
||||
entity_id=existing.id,
|
||||
local_data=task_data,
|
||||
server_data=TaskSerializer(existing).data,
|
||||
)
|
||||
conflicts.append(conflict)
|
||||
else:
|
||||
# Safe to update
|
||||
update_task_from_data(existing, task_data)
|
||||
|
||||
except Task.DoesNotExist:
|
||||
if not is_deleted:
|
||||
# Create new task
|
||||
create_task_from_data(user, task_data)
|
||||
|
||||
return conflicts
|
||||
|
||||
|
||||
def process_tag_changes(user, tag_changes, last_sync_at):
|
||||
"""Process tag changes from client."""
|
||||
conflicts = []
|
||||
|
||||
for tag_data in tag_changes:
|
||||
sync_id = tag_data.get('sync_id')
|
||||
is_deleted = tag_data.get('is_deleted', False)
|
||||
|
||||
try:
|
||||
existing = Tag.all_objects.get(sync_id=sync_id, user=user)
|
||||
|
||||
if is_deleted:
|
||||
existing.is_deleted = True
|
||||
existing.save()
|
||||
continue
|
||||
|
||||
if last_sync_at and existing.updated_at > last_sync_at:
|
||||
conflict = SyncConflict.objects.create(
|
||||
user=user,
|
||||
entity_type='tag',
|
||||
entity_id=existing.id,
|
||||
local_data=tag_data,
|
||||
server_data=TagSerializer(existing).data,
|
||||
)
|
||||
conflicts.append(conflict)
|
||||
else:
|
||||
existing.name = tag_data.get('name', existing.name)
|
||||
existing.description = tag_data.get('description', existing.description)
|
||||
existing.color = tag_data.get('color', existing.color)
|
||||
existing.icon = tag_data.get('icon', existing.icon)
|
||||
existing.sort_order = tag_data.get('sort_order', existing.sort_order)
|
||||
existing.is_archived = tag_data.get('is_archived', existing.is_archived)
|
||||
existing.save()
|
||||
|
||||
except Tag.DoesNotExist:
|
||||
if not is_deleted:
|
||||
Tag.objects.create(
|
||||
user=user,
|
||||
sync_id=sync_id,
|
||||
name=tag_data.get('name'),
|
||||
description=tag_data.get('description', ''),
|
||||
color=tag_data.get('color', '#3B82F6'),
|
||||
icon=tag_data.get('icon', ''),
|
||||
sort_order=tag_data.get('sort_order', 0),
|
||||
is_archived=tag_data.get('is_archived', False),
|
||||
)
|
||||
|
||||
return conflicts
|
||||
|
||||
|
||||
def process_time_entry_changes(user, entry_changes, last_sync_at):
|
||||
"""Process time entry changes from client."""
|
||||
conflicts = []
|
||||
|
||||
print(f"[SERVER SYNC DEBUG] Processing {len(entry_changes)} time entry changes")
|
||||
|
||||
for entry_data in entry_changes:
|
||||
sync_id = entry_data.get('sync_id')
|
||||
is_deleted = entry_data.get('is_deleted', False)
|
||||
print(f"[SERVER SYNC DEBUG] Time entry sync_id: {sync_id}, is_deleted: {is_deleted}")
|
||||
|
||||
try:
|
||||
existing = TimeEntry.all_objects.get(sync_id=sync_id, user=user)
|
||||
|
||||
if is_deleted:
|
||||
existing.is_deleted = True
|
||||
existing.save()
|
||||
continue
|
||||
|
||||
if last_sync_at and existing.updated_at > last_sync_at:
|
||||
conflict = SyncConflict.objects.create(
|
||||
user=user,
|
||||
entity_type='time_entry',
|
||||
entity_id=existing.id,
|
||||
local_data=entry_data,
|
||||
server_data=TimeEntrySerializer(existing).data,
|
||||
)
|
||||
conflicts.append(conflict)
|
||||
else:
|
||||
# Parse datetime strings if needed
|
||||
started_at = entry_data.get('started_at', existing.started_at)
|
||||
if isinstance(started_at, str):
|
||||
started_at = parse_datetime(started_at)
|
||||
|
||||
ended_at = entry_data.get('ended_at', existing.ended_at)
|
||||
if ended_at and isinstance(ended_at, str):
|
||||
ended_at = parse_datetime(ended_at)
|
||||
|
||||
existing.started_at = started_at
|
||||
existing.ended_at = ended_at
|
||||
existing.notes = entry_data.get('notes', existing.notes)
|
||||
existing.save()
|
||||
|
||||
except TimeEntry.DoesNotExist:
|
||||
if not is_deleted:
|
||||
task_sync_id = entry_data.get('task_sync_id')
|
||||
print(f"[SERVER SYNC DEBUG] Creating new time entry for task_sync_id: {task_sync_id}")
|
||||
print(f"[SERVER SYNC DEBUG] started_at: {entry_data.get('started_at')}, ended_at: {entry_data.get('ended_at')}")
|
||||
try:
|
||||
task = Task.objects.get(sync_id=task_sync_id, user=user)
|
||||
|
||||
# Parse datetime strings to datetime objects
|
||||
started_at = entry_data.get('started_at')
|
||||
if isinstance(started_at, str):
|
||||
started_at = parse_datetime(started_at)
|
||||
|
||||
ended_at = entry_data.get('ended_at')
|
||||
if ended_at and isinstance(ended_at, str):
|
||||
ended_at = parse_datetime(ended_at)
|
||||
|
||||
new_entry = TimeEntry.objects.create(
|
||||
user=user,
|
||||
task=task,
|
||||
sync_id=sync_id,
|
||||
started_at=started_at,
|
||||
ended_at=ended_at,
|
||||
notes=entry_data.get('notes', ''),
|
||||
)
|
||||
print(f"[SERVER SYNC DEBUG] Created time entry: {new_entry.id}, duration_seconds: {new_entry.duration_seconds}")
|
||||
except Task.DoesNotExist:
|
||||
print(f"[SERVER SYNC DEBUG] Task not found for sync_id: {task_sync_id}")
|
||||
pass # Skip if task doesn't exist
|
||||
|
||||
return conflicts
|
||||
|
||||
|
||||
def update_task_from_data(task, data):
|
||||
"""Update a task from sync data."""
|
||||
task.title = data.get('title', task.title)
|
||||
task.description = data.get('description', task.description)
|
||||
task.status = data.get('status', task.status)
|
||||
task.priority = data.get('priority', task.priority)
|
||||
task.due_date = data.get('due_date', task.due_date)
|
||||
task.due_time = data.get('due_time', task.due_time)
|
||||
task.reminder_at = data.get('reminder_at', task.reminder_at)
|
||||
task.recurrence = data.get('recurrence', task.recurrence)
|
||||
task.recurrence_rule = data.get('recurrence_rule', task.recurrence_rule)
|
||||
task.sort_order = data.get('sort_order', task.sort_order)
|
||||
|
||||
task.save()
|
||||
|
||||
# Handle tags
|
||||
tag_sync_ids = data.get('tag_sync_ids', [])
|
||||
if tag_sync_ids is not None:
|
||||
tags = Tag.objects.filter(sync_id__in=tag_sync_ids, user=task.user)
|
||||
task.tags.set(tags)
|
||||
|
||||
|
||||
def create_task_from_data(user, data):
|
||||
"""Create a new task from sync data."""
|
||||
parent = None
|
||||
parent_sync_id = data.get('parent_sync_id')
|
||||
if parent_sync_id:
|
||||
try:
|
||||
parent = Task.all_objects.get(sync_id=parent_sync_id, user=user)
|
||||
except Task.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Get sync_id and convert to UUID if provided as string
|
||||
sync_id = data.get('sync_id')
|
||||
if sync_id and isinstance(sync_id, str):
|
||||
sync_id = uuid.UUID(sync_id)
|
||||
|
||||
task = Task.objects.create(
|
||||
user=user,
|
||||
sync_id=sync_id,
|
||||
parent=parent,
|
||||
title=data.get('title'),
|
||||
description=data.get('description', ''),
|
||||
status=data.get('status', 'pending'),
|
||||
priority=data.get('priority', 'medium'),
|
||||
due_date=data.get('due_date'),
|
||||
due_time=data.get('due_time'),
|
||||
reminder_at=data.get('reminder_at'),
|
||||
recurrence=data.get('recurrence', 'none'),
|
||||
recurrence_rule=data.get('recurrence_rule', ''),
|
||||
sort_order=data.get('sort_order', 0),
|
||||
)
|
||||
|
||||
# Handle tags
|
||||
tag_sync_ids = data.get('tag_sync_ids', [])
|
||||
if tag_sync_ids:
|
||||
tags = Tag.objects.filter(sync_id__in=tag_sync_ids, user=user)
|
||||
task.tags.set(tags)
|
||||
|
||||
return task
|
||||
|
||||
|
||||
def get_server_changes(user, last_sync_at):
|
||||
"""Get all changes on server since last sync."""
|
||||
print(f"[SERVER SYNC DEBUG] get_server_changes: last_sync_at={last_sync_at}")
|
||||
filter_kwargs = {'user': user}
|
||||
|
||||
# For incremental sync, filter by updated_at
|
||||
# IMPORTANT: Use all_objects to include deleted items so clients can delete them too
|
||||
if last_sync_at:
|
||||
filter_kwargs['updated_at__gt'] = last_sync_at
|
||||
tasks = Task.all_objects.filter(**filter_kwargs).prefetch_related('tags')
|
||||
tags = Tag.all_objects.filter(**filter_kwargs)
|
||||
time_entries = TimeEntry.all_objects.filter(**filter_kwargs)
|
||||
else:
|
||||
# Full sync - send everything (including deleted items for cleanup)
|
||||
tasks = Task.all_objects.filter(user=user).prefetch_related('tags')
|
||||
tags = Tag.all_objects.filter(user=user)
|
||||
time_entries = TimeEntry.all_objects.filter(user=user)
|
||||
|
||||
print(f"[SERVER SYNC DEBUG] Returning {len(tasks)} tasks, {len(tags)} tags, {len(time_entries)} time entries")
|
||||
if time_entries:
|
||||
print(f"[SERVER SYNC DEBUG] Time entries being sent to client:")
|
||||
for entry in time_entries:
|
||||
print(f"[SERVER SYNC DEBUG] sync_id: {entry.sync_id}, task: {entry.task.title}, duration: {entry.duration_seconds}s, updated_at: {entry.updated_at}")
|
||||
|
||||
return {
|
||||
'tasks': TaskSyncSerializer(tasks, many=True).data,
|
||||
'tags': TagSyncSerializer(tags, many=True).data,
|
||||
'time_entries': TimeEntrySyncSerializer(time_entries, many=True).data,
|
||||
}
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_conflicts(request):
|
||||
"""Get pending sync conflicts for user."""
|
||||
conflicts = SyncConflict.objects.filter(
|
||||
user=request.user,
|
||||
status='pending'
|
||||
)
|
||||
return Response(SyncConflictSerializer(conflicts, many=True).data)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def resolve_conflict(request, conflict_id):
|
||||
"""
|
||||
Resolve a sync conflict.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"resolution": "local" | "server" | "merged",
|
||||
"merged_data": {...} // Only if resolution is "merged"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
conflict = SyncConflict.objects.get(
|
||||
id=conflict_id,
|
||||
user=request.user,
|
||||
status='pending'
|
||||
)
|
||||
except SyncConflict.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'Conflict not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
resolution = request.data.get('resolution')
|
||||
|
||||
if resolution == 'local':
|
||||
# Apply local changes
|
||||
apply_conflict_data(conflict.entity_type, conflict.entity_id, conflict.local_data)
|
||||
conflict.status = 'resolved_local'
|
||||
elif resolution == 'server':
|
||||
# Keep server version (no action needed)
|
||||
conflict.status = 'resolved_server'
|
||||
elif resolution == 'merged':
|
||||
merged_data = request.data.get('merged_data')
|
||||
if not merged_data:
|
||||
return Response(
|
||||
{'error': 'merged_data is required for merged resolution'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
apply_conflict_data(conflict.entity_type, conflict.entity_id, merged_data)
|
||||
conflict.status = 'resolved_merged'
|
||||
else:
|
||||
return Response(
|
||||
{'error': 'Invalid resolution type'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
conflict.resolved_at = timezone.now()
|
||||
conflict.save()
|
||||
|
||||
return Response({'status': 'resolved'})
|
||||
|
||||
|
||||
def apply_conflict_data(entity_type, entity_id, data):
|
||||
"""Apply conflict resolution data to entity."""
|
||||
if entity_type == 'task':
|
||||
try:
|
||||
task = Task.objects.get(id=entity_id)
|
||||
update_task_from_data(task, data)
|
||||
except Task.DoesNotExist:
|
||||
pass
|
||||
elif entity_type == 'tag':
|
||||
try:
|
||||
tag = Tag.objects.get(id=entity_id)
|
||||
tag.name = data.get('name', tag.name)
|
||||
tag.description = data.get('description', tag.description)
|
||||
tag.color = data.get('color', tag.color)
|
||||
tag.icon = data.get('icon', tag.icon)
|
||||
tag.sort_order = data.get('sort_order', tag.sort_order)
|
||||
tag.is_archived = data.get('is_archived', tag.is_archived)
|
||||
tag.save()
|
||||
except Tag.DoesNotExist:
|
||||
pass
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Task admin configuration.
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from .models import Task, Tag, TimeEntry, TaskShare
|
||||
|
||||
|
||||
@admin.register(Tag)
|
||||
class TagAdmin(admin.ModelAdmin):
|
||||
"""Admin for Tag."""
|
||||
|
||||
list_display = ['name', 'user', 'color', 'is_archived', 'created_at']
|
||||
list_filter = ['is_archived', 'created_at']
|
||||
search_fields = ['name', 'user__email']
|
||||
ordering = ['sort_order', 'name']
|
||||
|
||||
|
||||
@admin.register(Task)
|
||||
class TaskAdmin(admin.ModelAdmin):
|
||||
"""Admin for Task."""
|
||||
|
||||
list_display = ['title', 'user', 'status', 'priority', 'due_date', 'created_at']
|
||||
list_filter = ['status', 'priority', 'recurrence', 'created_at']
|
||||
search_fields = ['title', 'description', 'user__email']
|
||||
ordering = ['-created_at']
|
||||
raw_id_fields = ['user', 'parent']
|
||||
filter_horizontal = ['tags']
|
||||
|
||||
|
||||
@admin.register(TimeEntry)
|
||||
class TimeEntryAdmin(admin.ModelAdmin):
|
||||
"""Admin for TimeEntry."""
|
||||
|
||||
list_display = ['task', 'user', 'started_at', 'ended_at', 'duration_seconds']
|
||||
list_filter = ['started_at']
|
||||
search_fields = ['task__title', 'user__email']
|
||||
ordering = ['-started_at']
|
||||
raw_id_fields = ['task', 'user']
|
||||
|
||||
|
||||
@admin.register(TaskShare)
|
||||
class TaskShareAdmin(admin.ModelAdmin):
|
||||
"""Admin for TaskShare."""
|
||||
|
||||
list_display = ['owner', 'shared_with', 'task', 'tag', 'permission', 'created_at']
|
||||
list_filter = ['permission', 'created_at']
|
||||
search_fields = ['owner__email', 'shared_with__email']
|
||||
ordering = ['-created_at']
|
||||
raw_id_fields = ['owner', 'shared_with', 'task', 'tag']
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TasksConfig(AppConfig):
|
||||
name = 'tasks'
|
||||
@@ -0,0 +1,104 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-10 00:05
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Tag',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('color', models.CharField(default='#6B7280', max_length=7)),
|
||||
('sync_id', models.UUIDField(default=uuid.uuid4, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'tags',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Task',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('title', models.CharField(max_length=500)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('in_progress', 'In Progress'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='pending', max_length=20)),
|
||||
('priority', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('urgent', 'Urgent')], default='medium', max_length=20)),
|
||||
('due_date', models.DateField(blank=True, null=True)),
|
||||
('due_time', models.TimeField(blank=True, null=True)),
|
||||
('reminder_at', models.DateTimeField(blank=True, null=True)),
|
||||
('completed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('recurrence', models.CharField(choices=[('none', 'None'), ('daily', 'Daily'), ('weekly', 'Weekly'), ('biweekly', 'Bi-weekly'), ('monthly', 'Monthly'), ('yearly', 'Yearly'), ('custom', 'Custom')], default='none', max_length=20)),
|
||||
('recurrence_rule', models.CharField(blank=True, max_length=500)),
|
||||
('recurrence_end_date', models.DateField(blank=True, null=True)),
|
||||
('sort_order', models.IntegerField(default=0)),
|
||||
('sync_id', models.UUIDField(default=uuid.uuid4, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'tasks',
|
||||
'ordering': ['sort_order', '-priority', 'due_date', 'created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TaskGroup',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('color', models.CharField(default='#3B82F6', max_length=7)),
|
||||
('icon', models.CharField(blank=True, max_length=50)),
|
||||
('sort_order', models.IntegerField(default=0)),
|
||||
('is_archived', models.BooleanField(default=False)),
|
||||
('sync_id', models.UUIDField(default=uuid.uuid4, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'task_groups',
|
||||
'ordering': ['sort_order', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TaskShare',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('permission', models.CharField(choices=[('viewer', 'Viewer'), ('editor', 'Editor')], default='viewer', max_length=20)),
|
||||
('sync_id', models.UUIDField(default=uuid.uuid4, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'task_shares',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TimeEntry',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('started_at', models.DateTimeField()),
|
||||
('ended_at', models.DateTimeField(blank=True, null=True)),
|
||||
('duration_seconds', models.IntegerField(blank=True, null=True)),
|
||||
('notes', models.TextField(blank=True)),
|
||||
('sync_id', models.UUIDField(default=uuid.uuid4, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'time_entries',
|
||||
'ordering': ['-started_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,86 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-10 00:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('tasks', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tag',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tags', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='parent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subtasks', to='tasks.task'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='tags',
|
||||
field=models.ManyToManyField(blank=True, related_name='tasks', to='tasks.tag'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tag',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tags', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='group',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tasks', to='tasks.tag'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='taskshare',
|
||||
name='group',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='shares', to='tasks.tag'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='taskshare',
|
||||
name='owner',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shares_given', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='taskshare',
|
||||
name='shared_with',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shares_received', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='taskshare',
|
||||
name='task',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='shares', to='tasks.task'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='timeentry',
|
||||
name='task',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='time_entries', to='tasks.task'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='timeentry',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='time_entries', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='tag',
|
||||
unique_together={('user', 'name')},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='taskshare',
|
||||
constraint=models.CheckConstraint(condition=models.Q(models.Q(('group__isnull', True), ('task__isnull', False)), models.Q(('group__isnull', False), ('task__isnull', True)), _connector='OR'), name='share_task_or_group_not_both'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,135 @@
|
||||
# Migration to merge TaskGroup into Tag and support multiple tags per task
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def migrate_groups_to_tags(apps, schema_editor):
|
||||
"""Migrate TaskGroup data to Tag and convert task.group_id to task_tags."""
|
||||
TaskGroup = apps.get_model('tasks', 'TaskGroup')
|
||||
Tag = apps.get_model('tasks', 'Tag')
|
||||
Task = apps.get_model('tasks', 'Task')
|
||||
|
||||
# First, migrate all TaskGroups to Tags
|
||||
group_to_tag_map = {}
|
||||
for group in TaskGroup.objects.all():
|
||||
# Check if a tag with the same sync_id already exists
|
||||
existing_tag = Tag.objects.filter(sync_id=group.sync_id).first()
|
||||
if existing_tag:
|
||||
# Update existing tag with group data
|
||||
existing_tag.description = group.description
|
||||
existing_tag.icon = group.icon
|
||||
existing_tag.sort_order = group.sort_order
|
||||
existing_tag.is_archived = group.is_archived
|
||||
existing_tag.save()
|
||||
group_to_tag_map[group.id] = existing_tag
|
||||
else:
|
||||
# Create new tag from group
|
||||
tag = Tag.objects.create(
|
||||
user=group.user,
|
||||
name=group.name,
|
||||
description=group.description,
|
||||
color=group.color,
|
||||
icon=group.icon,
|
||||
sort_order=group.sort_order,
|
||||
is_archived=group.is_archived,
|
||||
sync_id=group.sync_id,
|
||||
)
|
||||
group_to_tag_map[group.id] = tag
|
||||
|
||||
# Convert task.group_id to task_tags entries
|
||||
for task in Task.objects.exclude(group_id__isnull=True):
|
||||
if task.group_id in group_to_tag_map:
|
||||
tag = group_to_tag_map[task.group_id]
|
||||
task.tags.add(tag)
|
||||
|
||||
|
||||
def reverse_migration(apps, schema_editor):
|
||||
"""Reverse migration - not fully reversible, just a placeholder."""
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tasks', '0002_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Step 1: Add new fields to Tag model
|
||||
migrations.AddField(
|
||||
model_name='tag',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, default=''),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tag',
|
||||
name='icon',
|
||||
field=models.CharField(blank=True, default='', max_length=50),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tag',
|
||||
name='sort_order',
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tag',
|
||||
name='is_archived',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
|
||||
# Step 2: Remove unique_together constraint on Tag (user, name)
|
||||
# to allow migration of groups with same names
|
||||
migrations.AlterUniqueTogether(
|
||||
name='tag',
|
||||
unique_together=set(),
|
||||
),
|
||||
|
||||
# Step 3: Change Tag ordering
|
||||
migrations.AlterModelOptions(
|
||||
name='tag',
|
||||
options={'ordering': ['sort_order', 'name']},
|
||||
),
|
||||
|
||||
# Step 4: Run data migration
|
||||
migrations.RunPython(migrate_groups_to_tags, reverse_migration),
|
||||
|
||||
# Step 5: Remove group field from Task
|
||||
migrations.RemoveField(
|
||||
model_name='task',
|
||||
name='group',
|
||||
),
|
||||
|
||||
# Step 6: Update TaskShare - remove old constraint first
|
||||
migrations.RemoveConstraint(
|
||||
model_name='taskshare',
|
||||
name='share_task_or_group_not_both',
|
||||
),
|
||||
|
||||
# Step 7: Rename group field to tag in TaskShare
|
||||
migrations.RenameField(
|
||||
model_name='taskshare',
|
||||
old_name='group',
|
||||
new_name='tag',
|
||||
),
|
||||
|
||||
# Step 8: Add new constraint for TaskShare
|
||||
migrations.AddConstraint(
|
||||
model_name='taskshare',
|
||||
constraint=models.CheckConstraint(
|
||||
check=models.Q(
|
||||
models.Q(('tag__isnull', True), ('task__isnull', False)),
|
||||
models.Q(('tag__isnull', False), ('task__isnull', True)),
|
||||
_connector='OR'
|
||||
),
|
||||
name='share_task_or_tag_not_both'
|
||||
),
|
||||
),
|
||||
|
||||
# Step 9: Delete TaskGroup model
|
||||
migrations.DeleteModel(
|
||||
name='TaskGroup',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-13 00:19
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tasks', '0003_merge_groups_to_tags'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='tag',
|
||||
name='color',
|
||||
field=models.CharField(default='#3B82F6', max_length=7),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tag',
|
||||
name='name',
|
||||
field=models.CharField(max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='taskshare',
|
||||
name='tag',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='shares', to='tasks.tag'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-13 06:45
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def fix_tasks_tags_table(apps, schema_editor):
|
||||
"""
|
||||
Fix the tasks_tags junction table to reference the correct 'tags' table
|
||||
instead of the old 'task_groups' table.
|
||||
"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
# Drop and recreate the tasks_tags table with correct foreign keys
|
||||
schema_editor.execute(
|
||||
"DROP TABLE IF EXISTS tasks_tags;"
|
||||
)
|
||||
|
||||
schema_editor.execute(
|
||||
"""
|
||||
CREATE TABLE tasks_tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id CHAR(32) NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
tag_id CHAR(32) NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
UNIQUE (task_id, tag_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
# Create indexes for performance
|
||||
schema_editor.execute(
|
||||
"CREATE INDEX tasks_tags_task_id_idx ON tasks_tags(task_id);"
|
||||
)
|
||||
schema_editor.execute(
|
||||
"CREATE INDEX tasks_tags_tag_id_idx ON tasks_tags(tag_id);"
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tasks', '0004_alter_tag_color_alter_tag_name_alter_taskshare_tag'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(fix_tasks_tags_table, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,61 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-13 07:00
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def fix_task_shares_table(apps, schema_editor):
|
||||
"""
|
||||
Fix the task_shares table to reference the correct 'tags' table
|
||||
instead of the old 'task_groups' table.
|
||||
"""
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
# Drop and recreate the task_shares table with correct foreign keys
|
||||
schema_editor.execute(
|
||||
"DROP TABLE IF EXISTS task_shares;"
|
||||
)
|
||||
|
||||
schema_editor.execute(
|
||||
"""
|
||||
CREATE TABLE task_shares (
|
||||
id CHAR(32) NOT NULL PRIMARY KEY,
|
||||
permission VARCHAR(20) NOT NULL,
|
||||
sync_id CHAR(32) NOT NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
owner_id CHAR(32) NOT NULL REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED,
|
||||
shared_with_id CHAR(32) NOT NULL REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED,
|
||||
task_id CHAR(32) NULL REFERENCES tasks(id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
|
||||
tag_id CHAR(32) NULL REFERENCES tags(id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
|
||||
CONSTRAINT share_task_or_tag_not_both CHECK (
|
||||
(tag_id IS NULL AND task_id IS NOT NULL) OR
|
||||
(tag_id IS NOT NULL AND task_id IS NULL)
|
||||
)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
# Create indexes for foreign keys
|
||||
schema_editor.execute(
|
||||
"CREATE INDEX task_shares_owner_id_idx ON task_shares(owner_id);"
|
||||
)
|
||||
schema_editor.execute(
|
||||
"CREATE INDEX task_shares_shared_with_id_idx ON task_shares(shared_with_id);"
|
||||
)
|
||||
schema_editor.execute(
|
||||
"CREATE INDEX task_shares_task_id_idx ON task_shares(task_id);"
|
||||
)
|
||||
schema_editor.execute(
|
||||
"CREATE INDEX task_shares_tag_id_idx ON task_shares(tag_id);"
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tasks', '0005_fix_tasks_tags_table'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(fix_task_shares_table, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-14 03:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tasks', '0006_fix_task_shares_table'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tag',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='task',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='timeentry',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
"""
|
||||
Task models for KeepItGoing.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class SoftDeleteManager(models.Manager):
|
||||
"""Manager that excludes soft-deleted items by default."""
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(is_deleted=False)
|
||||
|
||||
|
||||
class AllObjectsManager(models.Manager):
|
||||
"""Manager that includes soft-deleted items."""
|
||||
pass
|
||||
|
||||
|
||||
class Tag(models.Model):
|
||||
"""
|
||||
Tags for organizing and categorizing tasks.
|
||||
Supports multiple tags per task.
|
||||
"""
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='tags'
|
||||
)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
color = models.CharField(max_length=7, default='#3B82F6') # Hex color
|
||||
icon = models.CharField(max_length=50, blank=True) # Icon name
|
||||
sort_order = models.IntegerField(default=0)
|
||||
is_archived = models.BooleanField(default=False)
|
||||
|
||||
# Sync fields
|
||||
sync_id = models.UUIDField(default=uuid.uuid4, unique=True)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
# Managers
|
||||
objects = SoftDeleteManager()
|
||||
all_objects = AllObjectsManager()
|
||||
|
||||
class Meta:
|
||||
db_table = 'tags'
|
||||
ordering = ['sort_order', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Task(models.Model):
|
||||
"""
|
||||
Main task model with support for subtasks, recurrence, and time tracking.
|
||||
"""
|
||||
STATUS_CHOICES = [
|
||||
('pending', 'Pending'),
|
||||
('in_progress', 'In Progress'),
|
||||
('completed', 'Completed'),
|
||||
('cancelled', 'Cancelled'),
|
||||
]
|
||||
|
||||
PRIORITY_CHOICES = [
|
||||
('low', 'Low'),
|
||||
('medium', 'Medium'),
|
||||
('high', 'High'),
|
||||
('urgent', 'Urgent'),
|
||||
]
|
||||
|
||||
RECURRENCE_CHOICES = [
|
||||
('none', 'None'),
|
||||
('daily', 'Daily'),
|
||||
('weekly', 'Weekly'),
|
||||
('biweekly', 'Bi-weekly'),
|
||||
('monthly', 'Monthly'),
|
||||
('yearly', 'Yearly'),
|
||||
('custom', 'Custom'),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='tasks'
|
||||
)
|
||||
|
||||
# Hierarchy
|
||||
parent = models.ForeignKey(
|
||||
'self',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='subtasks'
|
||||
)
|
||||
|
||||
# Core fields
|
||||
title = models.CharField(max_length=500)
|
||||
description = models.TextField(blank=True)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
|
||||
priority = models.CharField(max_length=20, choices=PRIORITY_CHOICES, default='medium')
|
||||
|
||||
# Dates and times
|
||||
due_date = models.DateField(null=True, blank=True)
|
||||
due_time = models.TimeField(null=True, blank=True)
|
||||
reminder_at = models.DateTimeField(null=True, blank=True)
|
||||
completed_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Recurrence (RRULE format for custom)
|
||||
recurrence = models.CharField(max_length=20, choices=RECURRENCE_CHOICES, default='none')
|
||||
recurrence_rule = models.CharField(max_length=500, blank=True) # RRULE string
|
||||
recurrence_end_date = models.DateField(null=True, blank=True)
|
||||
|
||||
# Tags (multiple per task)
|
||||
tags = models.ManyToManyField(Tag, blank=True, related_name='tasks')
|
||||
|
||||
# Ordering
|
||||
sort_order = models.IntegerField(default=0)
|
||||
|
||||
# Sync fields
|
||||
sync_id = models.UUIDField(default=uuid.uuid4, unique=True)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
# Managers
|
||||
objects = SoftDeleteManager()
|
||||
all_objects = AllObjectsManager()
|
||||
|
||||
class Meta:
|
||||
db_table = 'tasks'
|
||||
ordering = ['sort_order', '-priority', 'due_date', 'created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Auto-set completed_at when status changes to completed
|
||||
if self.status == 'completed' and self.completed_at is None:
|
||||
from django.utils import timezone
|
||||
self.completed_at = timezone.now()
|
||||
elif self.status != 'completed':
|
||||
self.completed_at = None
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def is_overdue(self):
|
||||
"""Check if task is overdue."""
|
||||
if self.due_date and self.status not in ('completed', 'cancelled'):
|
||||
from django.utils import timezone
|
||||
return self.due_date < timezone.now().date()
|
||||
return False
|
||||
|
||||
@property
|
||||
def total_time_spent(self):
|
||||
"""Calculate total time spent on this task in seconds."""
|
||||
total_seconds = sum(
|
||||
entry.duration_seconds or 0
|
||||
for entry in self.time_entries.all()
|
||||
)
|
||||
return total_seconds
|
||||
|
||||
@property
|
||||
def total_time_formatted(self):
|
||||
"""Return total time spent formatted as H:MM:SS."""
|
||||
seconds = self.total_time_spent
|
||||
hours = seconds // 3600
|
||||
minutes = (seconds % 3600) // 60
|
||||
secs = seconds % 60
|
||||
return f"{hours}:{minutes:02d}:{secs:02d}"
|
||||
|
||||
|
||||
class TimeEntry(models.Model):
|
||||
"""
|
||||
Time tracking entries for tasks.
|
||||
"""
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name='time_entries')
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='time_entries'
|
||||
)
|
||||
|
||||
started_at = models.DateTimeField()
|
||||
ended_at = models.DateTimeField(null=True, blank=True)
|
||||
duration_seconds = models.IntegerField(null=True, blank=True)
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
# Sync fields
|
||||
sync_id = models.UUIDField(default=uuid.uuid4, unique=True)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
# Managers
|
||||
objects = SoftDeleteManager()
|
||||
all_objects = AllObjectsManager()
|
||||
|
||||
class Meta:
|
||||
db_table = 'time_entries'
|
||||
ordering = ['-started_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.task.title} - {self.started_at}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Calculate duration if ended
|
||||
if self.ended_at and self.started_at:
|
||||
delta = self.ended_at - self.started_at
|
||||
self.duration_seconds = int(delta.total_seconds())
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def is_running(self):
|
||||
"""Check if this time entry is currently running."""
|
||||
return self.ended_at is None
|
||||
|
||||
|
||||
class TaskShare(models.Model):
|
||||
"""
|
||||
Sharing tasks or tags with other users.
|
||||
"""
|
||||
PERMISSION_CHOICES = [
|
||||
('viewer', 'Viewer'),
|
||||
('editor', 'Editor'),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='shares_given'
|
||||
)
|
||||
shared_with = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='shares_received'
|
||||
)
|
||||
|
||||
# Can share either a task or a tag
|
||||
task = models.ForeignKey(
|
||||
Task,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='shares'
|
||||
)
|
||||
tag = models.ForeignKey(
|
||||
Tag,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='shares'
|
||||
)
|
||||
|
||||
permission = models.CharField(max_length=20, choices=PERMISSION_CHOICES, default='viewer')
|
||||
|
||||
# Sync fields
|
||||
sync_id = models.UUIDField(default=uuid.uuid4, unique=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'task_shares'
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=(
|
||||
models.Q(task__isnull=False, tag__isnull=True) |
|
||||
models.Q(task__isnull=True, tag__isnull=False)
|
||||
),
|
||||
name='share_task_or_tag_not_both'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
shared_item = self.task or self.tag
|
||||
return f"{shared_item} shared with {self.shared_with.email}"
|
||||
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Task serializers for KeepItGoing API.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth import get_user_model
|
||||
from .models import Task, Tag, TimeEntry, TaskShare
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class TagSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for tags."""
|
||||
|
||||
task_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Tag
|
||||
fields = [
|
||||
'id', 'name', 'description', 'color', 'icon', 'sort_order',
|
||||
'is_archived', 'task_count', 'sync_id', 'created_at', 'updated_at'
|
||||
]
|
||||
read_only_fields = ['id', 'sync_id', 'created_at', 'updated_at']
|
||||
|
||||
def get_task_count(self, obj):
|
||||
return obj.tasks.filter(status__in=['pending', 'in_progress']).count()
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['user'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class TimeEntrySerializer(serializers.ModelSerializer):
|
||||
"""Serializer for time entries."""
|
||||
|
||||
is_running = serializers.ReadOnlyField()
|
||||
|
||||
class Meta:
|
||||
model = TimeEntry
|
||||
fields = [
|
||||
'id', 'task', 'started_at', 'ended_at', 'duration_seconds',
|
||||
'notes', 'is_running', 'sync_id', 'created_at', 'updated_at'
|
||||
]
|
||||
read_only_fields = ['id', 'sync_id', 'created_at', 'updated_at', 'duration_seconds']
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['user'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class TaskSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for tasks."""
|
||||
|
||||
tags = TagSerializer(many=True, read_only=True)
|
||||
tag_ids = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Tag.objects.all(),
|
||||
many=True,
|
||||
write_only=True,
|
||||
required=False,
|
||||
source='tags'
|
||||
)
|
||||
tag_sync_ids = serializers.SerializerMethodField()
|
||||
subtasks = serializers.SerializerMethodField()
|
||||
is_overdue = serializers.ReadOnlyField()
|
||||
total_time_spent = serializers.ReadOnlyField()
|
||||
parent_sync_id = serializers.UUIDField(source='parent.sync_id', read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Task
|
||||
fields = [
|
||||
'id', 'parent', 'parent_sync_id',
|
||||
'title', 'description', 'status', 'priority', 'due_date', 'due_time',
|
||||
'reminder_at', 'completed_at', 'recurrence', 'recurrence_rule',
|
||||
'recurrence_end_date', 'tags', 'tag_ids', 'tag_sync_ids', 'subtasks',
|
||||
'sort_order', 'is_overdue', 'total_time_spent', 'sync_id',
|
||||
'created_at', 'updated_at'
|
||||
]
|
||||
read_only_fields = ['id', 'sync_id', 'created_at', 'updated_at', 'completed_at']
|
||||
|
||||
def get_tag_sync_ids(self, obj):
|
||||
"""Return list of tag sync_ids for sync purposes."""
|
||||
return [str(tag.sync_id) for tag in obj.tags.all()]
|
||||
|
||||
def get_subtasks(self, obj):
|
||||
# Only include subtasks for top-level tasks to avoid recursion
|
||||
if obj.parent is None:
|
||||
subtasks = obj.subtasks.all()
|
||||
return TaskSerializer(subtasks, many=True, context=self.context).data
|
||||
return []
|
||||
|
||||
def create(self, validated_data):
|
||||
tags = validated_data.pop('tags', [])
|
||||
validated_data['user'] = self.context['request'].user
|
||||
task = super().create(validated_data)
|
||||
task.tags.set(tags)
|
||||
return task
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
tags = validated_data.pop('tags', None)
|
||||
task = super().update(instance, validated_data)
|
||||
if tags is not None:
|
||||
task.tags.set(tags)
|
||||
return task
|
||||
|
||||
|
||||
class TaskListSerializer(serializers.ModelSerializer):
|
||||
"""Lightweight serializer for task lists."""
|
||||
|
||||
tags = TagSerializer(many=True, read_only=True)
|
||||
subtask_count = serializers.SerializerMethodField()
|
||||
is_overdue = serializers.ReadOnlyField()
|
||||
|
||||
class Meta:
|
||||
model = Task
|
||||
fields = [
|
||||
'id', 'title', 'status', 'priority', 'due_date', 'due_time',
|
||||
'tags', 'subtask_count', 'is_overdue', 'sort_order', 'sync_id', 'updated_at'
|
||||
]
|
||||
|
||||
def get_subtask_count(self, obj):
|
||||
return obj.subtasks.count()
|
||||
|
||||
|
||||
class TaskSyncSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for tasks in sync responses - uses sync_id as id."""
|
||||
|
||||
id = serializers.UUIDField(source='sync_id', read_only=True)
|
||||
tag_sync_ids = serializers.SerializerMethodField()
|
||||
parent_sync_id = serializers.UUIDField(source='parent.sync_id', read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Task
|
||||
fields = [
|
||||
'id', 'parent', 'parent_sync_id',
|
||||
'title', 'description', 'status', 'priority', 'due_date', 'due_time',
|
||||
'reminder_at', 'completed_at', 'recurrence', 'recurrence_rule',
|
||||
'recurrence_end_date', 'tag_sync_ids',
|
||||
'sort_order', 'is_deleted', 'created_at', 'updated_at'
|
||||
]
|
||||
|
||||
def get_tag_sync_ids(self, obj):
|
||||
"""Return list of tag sync_ids for sync purposes."""
|
||||
return [str(tag.sync_id) for tag in obj.tags.all()]
|
||||
|
||||
|
||||
class TagSyncSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for tags in sync responses - uses sync_id as id."""
|
||||
|
||||
id = serializers.UUIDField(source='sync_id', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Tag
|
||||
fields = ['id', 'name', 'description', 'color', 'icon', 'sort_order',
|
||||
'is_archived', 'is_deleted', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class TimeEntrySyncSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for time entries in sync responses - uses sync_id as id."""
|
||||
|
||||
id = serializers.UUIDField(source='sync_id', read_only=True)
|
||||
task = serializers.UUIDField(source='task.sync_id', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = TimeEntry
|
||||
fields = ['id', 'task', 'started_at', 'ended_at', 'notes',
|
||||
'is_deleted', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class TaskShareSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for task sharing."""
|
||||
|
||||
shared_with_email = serializers.EmailField(write_only=True)
|
||||
shared_with_username = serializers.CharField(source='shared_with.username', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = TaskShare
|
||||
fields = [
|
||||
'id', 'task', 'tag', 'shared_with_email', 'shared_with_username',
|
||||
'permission', 'sync_id', 'created_at'
|
||||
]
|
||||
read_only_fields = ['id', 'sync_id', 'created_at']
|
||||
|
||||
def validate_shared_with_email(self, value):
|
||||
try:
|
||||
user = User.objects.get(email=value)
|
||||
if user == self.context['request'].user:
|
||||
raise serializers.ValidationError("Cannot share with yourself.")
|
||||
return value
|
||||
except User.DoesNotExist:
|
||||
raise serializers.ValidationError("User with this email does not exist.")
|
||||
|
||||
def validate(self, attrs):
|
||||
if not attrs.get('task') and not attrs.get('tag'):
|
||||
raise serializers.ValidationError("Must share either a task or a tag.")
|
||||
if attrs.get('task') and attrs.get('tag'):
|
||||
raise serializers.ValidationError("Cannot share both a task and a tag.")
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
email = validated_data.pop('shared_with_email')
|
||||
validated_data['shared_with'] = User.objects.get(email=email)
|
||||
validated_data['owner'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
Task URLs for KeepItGoing.
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
# API URLs (to be included under /api/tasks/)
|
||||
api_urlpatterns = [
|
||||
path('', views.TaskListCreateAPIView.as_view(), name='api-task-list'),
|
||||
path('<uuid:pk>/', views.TaskDetailAPIView.as_view(), name='api-task-detail'),
|
||||
path('tags/', views.TagListCreateAPIView.as_view(), name='api-tag-list'),
|
||||
path('tags/<uuid:pk>/', views.TagDetailAPIView.as_view(), name='api-tag-detail'),
|
||||
path('time-entries/', views.TimeEntryListCreateAPIView.as_view(), name='api-time-entry-list'),
|
||||
path('time-entries/<uuid:pk>/', views.TimeEntryDetailAPIView.as_view(), name='api-time-entry-detail'),
|
||||
path('<uuid:task_id>/start-timer/', views.start_timer, name='api-start-timer'),
|
||||
path('time-entries/<uuid:entry_id>/stop/', views.stop_timer, name='api-stop-timer'),
|
||||
path('shares/', views.TaskShareListCreateAPIView.as_view(), name='api-share-list'),
|
||||
path('shares/<uuid:pk>/', views.TaskShareDeleteAPIView.as_view(), name='api-share-delete'),
|
||||
]
|
||||
|
||||
# Web URLs (to be included at root level)
|
||||
web_urlpatterns = [
|
||||
path('', views.DashboardView.as_view(), name='dashboard'),
|
||||
path('tasks/', views.TaskListView.as_view(), name='task-list'),
|
||||
path('tasks/new/', views.TaskCreateView.as_view(), name='task-create'),
|
||||
path('tasks/<uuid:task_id>/', views.TaskDetailView.as_view(), name='task-detail'),
|
||||
path('tasks/<uuid:task_id>/detail-partial/', views.task_detail_partial, name='task-detail-partial'),
|
||||
path('tasks/quick-add/', views.task_quick_add, name='task-quick-add'),
|
||||
path('tasks/<uuid:task_id>/toggle/', views.task_toggle_status, name='task-toggle'),
|
||||
path('tasks/<uuid:task_id>/delete/', views.task_delete, name='task-delete'),
|
||||
path('tasks/<uuid:task_id>/subtask/', views.subtask_create, name='subtask-create'),
|
||||
path('tasks/<uuid:task_id>/timer/start/', views.web_timer_start, name='timer-start'),
|
||||
path('tasks/<uuid:task_id>/timer/stop/', views.web_timer_stop, name='timer-stop'),
|
||||
path('tags/', views.TagListView.as_view(), name='tag-list'),
|
||||
path('tags/new/', views.TagListView.as_view(), name='tag-create'),
|
||||
path('tags/<uuid:tag_id>/', views.TagDetailView.as_view(), name='tag-detail'),
|
||||
path('tags/<uuid:tag_id>/delete/', views.tag_delete, name='tag-delete'),
|
||||
]
|
||||
+664
@@ -0,0 +1,664 @@
|
||||
"""
|
||||
Task views for KeepItGoing.
|
||||
"""
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.views import View
|
||||
from django.views.decorators.http import require_POST
|
||||
from rest_framework import generics, permissions, status, filters
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.response import Response
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
|
||||
from .models import Task, Tag, TimeEntry, TaskShare
|
||||
from .serializers import (
|
||||
TaskSerializer,
|
||||
TaskListSerializer,
|
||||
TagSerializer,
|
||||
TimeEntrySerializer,
|
||||
TaskShareSerializer,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API Views
|
||||
# =============================================================================
|
||||
|
||||
class TaskListCreateAPIView(generics.ListCreateAPIView):
|
||||
"""API endpoint for listing and creating tasks."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||
filterset_fields = ['status', 'priority', 'parent']
|
||||
search_fields = ['title', 'description']
|
||||
ordering_fields = ['due_date', 'priority', 'created_at', 'sort_order']
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.request.method == 'GET':
|
||||
return TaskListSerializer
|
||||
return TaskSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
# Include own tasks and shared tasks
|
||||
own_tasks = Task.objects.filter(user=user)
|
||||
shared_task_ids = TaskShare.objects.filter(
|
||||
shared_with=user, task__isnull=False
|
||||
).values_list('task_id', flat=True)
|
||||
shared_tag_ids = TaskShare.objects.filter(
|
||||
shared_with=user, tag__isnull=False
|
||||
).values_list('tag_id', flat=True)
|
||||
|
||||
return Task.objects.filter(
|
||||
Q(id__in=own_tasks) |
|
||||
Q(id__in=shared_task_ids) |
|
||||
Q(tags__id__in=shared_tag_ids)
|
||||
).prefetch_related('tags').distinct()
|
||||
|
||||
|
||||
class TaskDetailAPIView(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""API endpoint for task details."""
|
||||
|
||||
serializer_class = TaskSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
return Task.objects.filter(
|
||||
Q(user=user) |
|
||||
Q(shares__shared_with=user) |
|
||||
Q(tags__shares__shared_with=user)
|
||||
).distinct()
|
||||
|
||||
|
||||
class TagListCreateAPIView(generics.ListCreateAPIView):
|
||||
"""API endpoint for listing and creating tags."""
|
||||
|
||||
serializer_class = TagSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
return Tag.objects.filter(
|
||||
Q(user=user) | Q(shares__shared_with=user)
|
||||
).distinct()
|
||||
|
||||
|
||||
class TagDetailAPIView(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""API endpoint for tag details."""
|
||||
|
||||
serializer_class = TagSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
return Tag.objects.filter(
|
||||
Q(user=user) | Q(shares__shared_with=user)
|
||||
).distinct()
|
||||
|
||||
|
||||
class TimeEntryListCreateAPIView(generics.ListCreateAPIView):
|
||||
"""API endpoint for time entries."""
|
||||
|
||||
serializer_class = TimeEntrySerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
filterset_fields = ['task']
|
||||
|
||||
def get_queryset(self):
|
||||
return TimeEntry.objects.filter(user=self.request.user)
|
||||
|
||||
|
||||
class TimeEntryDetailAPIView(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""API endpoint for time entry details."""
|
||||
|
||||
serializer_class = TimeEntrySerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return TimeEntry.objects.filter(user=self.request.user)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([permissions.IsAuthenticated])
|
||||
def start_timer(request, task_id):
|
||||
"""Start a timer for a task."""
|
||||
task = get_object_or_404(Task, id=task_id, user=request.user)
|
||||
|
||||
# Check for existing running timer
|
||||
running = TimeEntry.objects.filter(
|
||||
user=request.user,
|
||||
ended_at__isnull=True
|
||||
).first()
|
||||
|
||||
if running:
|
||||
return Response(
|
||||
{'error': 'You already have a running timer. Stop it first.'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
entry = TimeEntry.objects.create(
|
||||
task=task,
|
||||
user=request.user,
|
||||
started_at=timezone.now()
|
||||
)
|
||||
|
||||
return Response(TimeEntrySerializer(entry).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([permissions.IsAuthenticated])
|
||||
def stop_timer(request, entry_id):
|
||||
"""Stop a running timer."""
|
||||
entry = get_object_or_404(
|
||||
TimeEntry,
|
||||
id=entry_id,
|
||||
user=request.user,
|
||||
ended_at__isnull=True
|
||||
)
|
||||
|
||||
entry.ended_at = timezone.now()
|
||||
entry.save()
|
||||
|
||||
return Response(TimeEntrySerializer(entry).data)
|
||||
|
||||
|
||||
class TaskShareListCreateAPIView(generics.ListCreateAPIView):
|
||||
"""API endpoint for sharing tasks."""
|
||||
|
||||
serializer_class = TaskShareSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return TaskShare.objects.filter(
|
||||
Q(owner=self.request.user) | Q(shared_with=self.request.user)
|
||||
)
|
||||
|
||||
|
||||
class TaskShareDeleteAPIView(generics.DestroyAPIView):
|
||||
"""API endpoint for removing a share."""
|
||||
|
||||
serializer_class = TaskShareSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return TaskShare.objects.filter(owner=self.request.user)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Web Views (Django Templates)
|
||||
# =============================================================================
|
||||
|
||||
class DashboardView(View):
|
||||
"""Main dashboard view with 3-pane layout."""
|
||||
|
||||
def get(self, request):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect('login')
|
||||
|
||||
today = timezone.now().date()
|
||||
|
||||
# Get filter parameters
|
||||
current_filter = request.GET.get('filter', 'all')
|
||||
current_tag_id = request.GET.get('tag')
|
||||
selected_task_id = request.GET.get('selected')
|
||||
current_sort = request.GET.get('sort', 'default')
|
||||
|
||||
# Base query
|
||||
tasks = Task.objects.filter(
|
||||
user=request.user,
|
||||
parent__isnull=True,
|
||||
).prefetch_related('tags', 'subtasks')
|
||||
|
||||
# Apply status filter (exclude completed by default unless showing all)
|
||||
if current_filter != 'completed':
|
||||
tasks = tasks.exclude(status='completed')
|
||||
|
||||
# Apply filters (can combine tag + date filter)
|
||||
page_title_parts = []
|
||||
|
||||
# Apply date/status filter first
|
||||
if current_filter == 'today':
|
||||
tasks = tasks.filter(due_date=today)
|
||||
page_title_parts.append("Today")
|
||||
elif current_filter == 'upcoming':
|
||||
tasks = tasks.filter(due_date__gt=today)
|
||||
page_title_parts.append("Upcoming")
|
||||
elif current_filter == 'overdue':
|
||||
tasks = [t for t in tasks if t.is_overdue]
|
||||
page_title_parts.append("Overdue")
|
||||
elif current_filter == 'completed':
|
||||
tasks = tasks.filter(status='completed')
|
||||
page_title_parts.append("Completed")
|
||||
else:
|
||||
page_title_parts.append("All Tasks")
|
||||
|
||||
# Apply tag filter
|
||||
if current_tag_id:
|
||||
# Filter for tasks with specific tag
|
||||
if isinstance(tasks, list):
|
||||
tasks = [t for t in tasks if t.tags.filter(id=current_tag_id).exists()]
|
||||
else:
|
||||
tasks = tasks.filter(tags__id=current_tag_id)
|
||||
tag = Tag.objects.filter(id=current_tag_id, user=request.user).first()
|
||||
if tag:
|
||||
page_title_parts.append(tag.name)
|
||||
|
||||
# Build page title
|
||||
page_title = " • ".join(page_title_parts) if len(page_title_parts) > 1 else page_title_parts[0]
|
||||
|
||||
# Apply sorting before converting to list
|
||||
if not isinstance(tasks, list):
|
||||
if current_sort == 'due_date':
|
||||
# Sort by due date (nulls last), then priority
|
||||
from django.db.models import F
|
||||
tasks = tasks.order_by(F('due_date').asc(nulls_last=True), '-priority')
|
||||
elif current_sort == 'due_date_desc':
|
||||
# Sort by due date descending (nulls last), then priority
|
||||
from django.db.models import F
|
||||
tasks = tasks.order_by(F('due_date').desc(nulls_last=True), '-priority')
|
||||
elif current_sort == 'priority':
|
||||
# Sort by priority, then due date
|
||||
tasks = tasks.order_by('-priority', 'due_date')
|
||||
elif current_sort == 'priority_low':
|
||||
# Sort by priority (low to high), then due date
|
||||
tasks = tasks.order_by('priority', 'due_date')
|
||||
# else: default ordering from model (sort_order, -priority, due_date, created_at)
|
||||
|
||||
# Convert to list if not already (for overdue filter)
|
||||
if not isinstance(tasks, list):
|
||||
tasks = list(tasks)
|
||||
|
||||
# Apply sorting to lists (for overdue filter case)
|
||||
else:
|
||||
priority_order = {'high': 3, 'medium': 2, 'low': 1}
|
||||
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)))
|
||||
elif current_sort == 'due_date_desc':
|
||||
tasks = sorted(tasks, key=lambda t: (t.due_date or timezone.now().date() - timezone.timedelta(days=9999), -priority_order.get(t.priority, 0)), reverse=True)
|
||||
elif current_sort == 'priority':
|
||||
tasks = sorted(tasks, key=lambda t: (-priority_order.get(t.priority, 0), t.due_date or timezone.now().date() + timezone.timedelta(days=9999)))
|
||||
elif current_sort == 'priority_low':
|
||||
tasks = sorted(tasks, key=lambda t: (priority_order.get(t.priority, 0), t.due_date or timezone.now().date() + timezone.timedelta(days=9999)))
|
||||
|
||||
# Get all tags for sidebar with task counts
|
||||
from django.db.models import Count, Q
|
||||
tags = Tag.objects.filter(
|
||||
user=request.user,
|
||||
is_archived=False
|
||||
).annotate(
|
||||
task_count=Count(
|
||||
'tasks',
|
||||
filter=Q(tasks__parent__isnull=True) & ~Q(tasks__status='completed'),
|
||||
distinct=True
|
||||
)
|
||||
)
|
||||
|
||||
# Task counts for sidebar
|
||||
all_tasks = Task.objects.filter(
|
||||
user=request.user,
|
||||
parent__isnull=True
|
||||
).exclude(status='completed')
|
||||
|
||||
completed_tasks = Task.objects.filter(
|
||||
user=request.user,
|
||||
parent__isnull=True,
|
||||
status='completed'
|
||||
)
|
||||
|
||||
task_counts = {
|
||||
'all': all_tasks.count(),
|
||||
'today': all_tasks.filter(due_date=today).count(),
|
||||
'upcoming': all_tasks.filter(due_date__gt=today).count(),
|
||||
'overdue': sum(1 for t in all_tasks if t.is_overdue),
|
||||
'completed': completed_tasks.count(),
|
||||
}
|
||||
|
||||
# Check for running timer
|
||||
running_timer = TimeEntry.objects.filter(
|
||||
user=request.user,
|
||||
ended_at__isnull=True
|
||||
).select_related('task').first()
|
||||
|
||||
# Selected task for detail panel
|
||||
selected_task = None
|
||||
if selected_task_id:
|
||||
selected_task = Task.objects.filter(
|
||||
id=selected_task_id,
|
||||
user=request.user
|
||||
).prefetch_related('tags').first()
|
||||
|
||||
return render(request, 'tasks/dashboard.html', {
|
||||
'tasks': tasks,
|
||||
'page_title': page_title,
|
||||
'tags': tags,
|
||||
'task_counts': task_counts,
|
||||
'current_filter': current_filter,
|
||||
'current_tag_id': current_tag_id,
|
||||
'current_sort': current_sort,
|
||||
'selected_task_id': selected_task_id,
|
||||
'selected_task': selected_task,
|
||||
'running_timer': running_timer,
|
||||
})
|
||||
|
||||
|
||||
class TaskListView(View):
|
||||
"""Task list view with filtering."""
|
||||
|
||||
def get(self, request):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect('login')
|
||||
|
||||
tasks = Task.objects.filter(
|
||||
user=request.user,
|
||||
parent__isnull=True
|
||||
).prefetch_related('tags', 'subtasks')
|
||||
|
||||
# Apply filters
|
||||
status_filter = request.GET.get('status')
|
||||
tag_filter = request.GET.get('tag')
|
||||
priority_filter = request.GET.get('priority')
|
||||
|
||||
if status_filter:
|
||||
tasks = tasks.filter(status=status_filter)
|
||||
if tag_filter:
|
||||
tasks = tasks.filter(tags__id=tag_filter)
|
||||
if priority_filter:
|
||||
tasks = tasks.filter(priority=priority_filter)
|
||||
|
||||
tags = Tag.objects.filter(user=request.user, is_archived=False)
|
||||
|
||||
return render(request, 'tasks/task_list.html', {
|
||||
'tasks': tasks,
|
||||
'tags': tags,
|
||||
'current_status': status_filter,
|
||||
'current_tag': tag_filter,
|
||||
'current_priority': priority_filter,
|
||||
})
|
||||
|
||||
|
||||
class TaskDetailView(View):
|
||||
"""Task detail view."""
|
||||
|
||||
def get(self, request, task_id):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect('login')
|
||||
|
||||
task = get_object_or_404(Task, id=task_id, user=request.user)
|
||||
time_entries = task.time_entries.all()[:10]
|
||||
tags = Tag.objects.filter(user=request.user)
|
||||
|
||||
return render(request, 'tasks/task_detail.html', {
|
||||
'task': task,
|
||||
'time_entries': time_entries,
|
||||
'tags': tags,
|
||||
})
|
||||
|
||||
def post(self, request, task_id):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect('login')
|
||||
|
||||
task = get_object_or_404(Task, id=task_id, user=request.user)
|
||||
|
||||
task.title = request.POST.get('title', task.title)
|
||||
task.description = request.POST.get('description', '')
|
||||
task.status = request.POST.get('status', task.status)
|
||||
task.priority = request.POST.get('priority', task.priority)
|
||||
|
||||
due_date = request.POST.get('due_date')
|
||||
task.due_date = due_date if due_date else None
|
||||
|
||||
due_time = request.POST.get('due_time')
|
||||
task.due_time = due_time if due_time else None
|
||||
|
||||
task.recurrence = request.POST.get('recurrence', 'none')
|
||||
|
||||
task.save()
|
||||
|
||||
# Handle tags
|
||||
tag_ids = request.POST.getlist('tags')
|
||||
task.tags.set(tag_ids)
|
||||
|
||||
# Redirect to next URL if provided, otherwise back to task detail
|
||||
next_url = request.POST.get('next')
|
||||
if next_url:
|
||||
return redirect(next_url)
|
||||
|
||||
return redirect('task-detail', task_id=task.id)
|
||||
|
||||
|
||||
class TaskCreateView(View):
|
||||
"""Create new task."""
|
||||
|
||||
def get(self, request):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect('login')
|
||||
|
||||
tags = Tag.objects.filter(user=request.user, is_archived=False)
|
||||
|
||||
return render(request, 'tasks/task_create.html', {
|
||||
'tags': tags,
|
||||
})
|
||||
|
||||
def post(self, request):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect('login')
|
||||
|
||||
task = Task.objects.create(
|
||||
user=request.user,
|
||||
title=request.POST.get('title'),
|
||||
description=request.POST.get('description', ''),
|
||||
status=request.POST.get('status', 'pending'),
|
||||
priority=request.POST.get('priority', 'medium'),
|
||||
due_date=request.POST.get('due_date') or None,
|
||||
due_time=request.POST.get('due_time') or None,
|
||||
recurrence=request.POST.get('recurrence', 'none'),
|
||||
)
|
||||
|
||||
tag_ids = request.POST.getlist('tags')
|
||||
task.tags.set(tag_ids)
|
||||
|
||||
return redirect('task-detail', task_id=task.id)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def task_quick_add(request):
|
||||
"""Quick add task from dashboard."""
|
||||
task = Task.objects.create(
|
||||
user=request.user,
|
||||
title=request.POST.get('title'),
|
||||
due_date=request.POST.get('due_date') or None,
|
||||
)
|
||||
# Handle optional tag
|
||||
tag_id = request.POST.get('tag')
|
||||
if tag_id:
|
||||
task.tags.add(tag_id)
|
||||
|
||||
next_url = request.POST.get('next', 'dashboard')
|
||||
return redirect(next_url)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def subtask_create(request, task_id):
|
||||
"""Create a subtask for a task."""
|
||||
parent_task = get_object_or_404(Task, id=task_id, user=request.user)
|
||||
|
||||
title = request.POST.get('title', '').strip()
|
||||
if title:
|
||||
subtask = Task.objects.create(
|
||||
user=request.user,
|
||||
title=title,
|
||||
parent=parent_task,
|
||||
)
|
||||
# Inherit tags from parent
|
||||
subtask.tags.set(parent_task.tags.all())
|
||||
|
||||
next_url = request.POST.get('next', request.META.get('HTTP_REFERER', 'dashboard'))
|
||||
return redirect(next_url)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def task_toggle_status(request, task_id):
|
||||
"""Toggle task completion status."""
|
||||
task = get_object_or_404(Task, id=task_id, user=request.user)
|
||||
|
||||
if task.status == 'completed':
|
||||
task.status = 'pending'
|
||||
else:
|
||||
task.status = 'completed'
|
||||
task.save()
|
||||
|
||||
next_url = request.POST.get('next', 'dashboard')
|
||||
return redirect(next_url)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def task_delete(request, task_id):
|
||||
"""Delete a task (soft delete)."""
|
||||
task = get_object_or_404(Task, id=task_id, user=request.user)
|
||||
task.is_deleted = True
|
||||
task.save()
|
||||
|
||||
next_url = request.POST.get('next', 'dashboard')
|
||||
return redirect(next_url)
|
||||
|
||||
|
||||
@login_required
|
||||
def task_detail_partial(request, task_id):
|
||||
"""Return task detail HTML partial for AJAX requests."""
|
||||
task = get_object_or_404(Task, id=task_id, user=request.user)
|
||||
tags = Tag.objects.filter(user=request.user, is_archived=False)
|
||||
|
||||
# Check for running timer
|
||||
running_timer = TimeEntry.objects.filter(
|
||||
user=request.user,
|
||||
ended_at__isnull=True
|
||||
).select_related('task').first()
|
||||
|
||||
return render(request, 'tasks/_task_detail.html', {
|
||||
'task': task,
|
||||
'tags': tags,
|
||||
'running_timer': running_timer,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def web_timer_start(request, task_id):
|
||||
"""Start a timer for a task (web view)."""
|
||||
task = get_object_or_404(Task, id=task_id, user=request.user)
|
||||
print(f"[WEB TIMER DEBUG] Starting timer for task: {task.title} (id: {task_id})")
|
||||
|
||||
# Stop any existing running timer first (properly to calculate duration)
|
||||
running_entries = TimeEntry.objects.filter(
|
||||
user=request.user,
|
||||
ended_at__isnull=True
|
||||
)
|
||||
|
||||
stopped_count = 0
|
||||
for entry in running_entries:
|
||||
entry.ended_at = timezone.now()
|
||||
entry.save() # This will calculate duration_seconds
|
||||
stopped_count += 1
|
||||
|
||||
print(f"[WEB TIMER DEBUG] Stopped {stopped_count} existing timers")
|
||||
|
||||
# Create new timer
|
||||
entry = TimeEntry.objects.create(
|
||||
task=task,
|
||||
user=request.user,
|
||||
started_at=timezone.now()
|
||||
)
|
||||
print(f"[WEB TIMER DEBUG] Created new timer entry: {entry.id}")
|
||||
|
||||
next_url = request.POST.get('next', 'dashboard')
|
||||
return redirect(next_url)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def web_timer_stop(request, task_id):
|
||||
"""Stop a timer for a task (web view)."""
|
||||
print(f"[WEB TIMER DEBUG] Stopping timer for task_id: {task_id}")
|
||||
|
||||
# Get the running timer and stop it properly (to trigger save() and calculate duration)
|
||||
entries = TimeEntry.objects.filter(
|
||||
user=request.user,
|
||||
task_id=task_id,
|
||||
ended_at__isnull=True
|
||||
)
|
||||
|
||||
stopped_count = 0
|
||||
for entry in entries:
|
||||
entry.ended_at = timezone.now()
|
||||
entry.save() # This will calculate duration_seconds
|
||||
stopped_count += 1
|
||||
|
||||
print(f"[WEB TIMER DEBUG] Stopped {stopped_count} timer entries")
|
||||
|
||||
next_url = request.POST.get('next', 'dashboard')
|
||||
return redirect(next_url)
|
||||
|
||||
|
||||
class TagListView(View):
|
||||
"""List tags."""
|
||||
|
||||
def get(self, request):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect('login')
|
||||
|
||||
tags = Tag.objects.filter(user=request.user)
|
||||
return render(request, 'tasks/tag_list.html', {'tags': tags})
|
||||
|
||||
def post(self, request):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect('login')
|
||||
|
||||
Tag.objects.create(
|
||||
user=request.user,
|
||||
name=request.POST.get('name'),
|
||||
color=request.POST.get('color', '#3B82F6'),
|
||||
)
|
||||
return redirect('tag-list')
|
||||
|
||||
|
||||
class TagDetailView(View):
|
||||
"""Edit a tag."""
|
||||
|
||||
def get(self, request, tag_id):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect('login')
|
||||
|
||||
tag = get_object_or_404(Tag, id=tag_id, user=request.user)
|
||||
return render(request, 'tasks/tag_edit.html', {'tag': tag})
|
||||
|
||||
def post(self, request, tag_id):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect('login')
|
||||
|
||||
tag = get_object_or_404(Tag, id=tag_id, user=request.user)
|
||||
tag.name = request.POST.get('name', tag.name)
|
||||
tag.color = request.POST.get('color', tag.color)
|
||||
tag.description = request.POST.get('description', tag.description)
|
||||
tag.save()
|
||||
|
||||
next_url = request.POST.get('next', 'tag-list')
|
||||
return redirect(next_url)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def tag_delete(request, tag_id):
|
||||
"""Delete a tag (soft delete)."""
|
||||
tag = get_object_or_404(Tag, id=tag_id, user=request.user)
|
||||
tag.is_deleted = True
|
||||
tag.save()
|
||||
|
||||
next_url = request.POST.get('next', 'tag-list')
|
||||
return redirect(next_url)
|
||||
@@ -0,0 +1,140 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}KeepItGoing{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{% static 'css/app.css' %}">
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}
|
||||
{% if user.is_authenticated %}
|
||||
<div class="app-layout {% if not selected_task %}detail-closed{% endif %}" id="app">
|
||||
<!-- Header -->
|
||||
<header class="app-header">
|
||||
<div style="display: flex; align-items: center; gap: var(--space-md);">
|
||||
<button class="sidebar-toggle" onclick="toggleSidebar()" aria-label="Toggle sidebar">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 12h18M3 6h18M3 18h18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="{% url 'dashboard' %}" class="app-brand">KeepItGoing</a>
|
||||
</div>
|
||||
|
||||
<div class="app-header-actions">
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle theme" title="Toggle dark/light mode">
|
||||
<svg id="theme-icon-light" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||
</svg>
|
||||
<svg id="theme-icon-dark" style="display: none;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="header-user">
|
||||
<span>{{ user.email }}</span>
|
||||
<a href="{% url 'logout' %}" class="btn btn-secondary btn-sm">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="app-sidebar" id="sidebar">
|
||||
{% include 'tasks/_sidebar.html' %}
|
||||
</aside>
|
||||
|
||||
<!-- Main Task Pane -->
|
||||
<main class="task-pane">
|
||||
{% if messages %}
|
||||
<div class="messages">
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Detail Panel -->
|
||||
<aside class="detail-pane {% if not selected_task %}hidden{% endif %}" id="detail-pane">
|
||||
{% block detail %}
|
||||
{% if selected_task %}
|
||||
{% include 'tasks/_task_detail.html' with task=selected_task %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>Select a task to view details</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
</aside>
|
||||
|
||||
<!-- Mobile overlay -->
|
||||
<div class="mobile-overlay" id="mobile-overlay" onclick="closeMobileMenus()"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tag Edit/Create Modal (outside app-layout for proper fixed positioning) -->
|
||||
<div class="modal-backdrop" id="tag-modal-backdrop">
|
||||
<div class="modal" onclick="event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="tag-modal-title">New Tag</h2>
|
||||
<button type="button" class="modal-close" onclick="closeTagModal()">
|
||||
<svg style="width: 20px; height: 20px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="tag-form" method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" id="tag-id" name="group_id" value="">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="tag-name">Name</label>
|
||||
<input type="text" class="form-input" id="tag-name" name="name" required placeholder="Tag name">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Color</label>
|
||||
<div class="color-presets">
|
||||
<button type="button" class="color-preset" data-color="#3b82f6" style="background-color: #3b82f6"></button>
|
||||
<button type="button" class="color-preset" data-color="#ef4444" style="background-color: #ef4444"></button>
|
||||
<button type="button" class="color-preset" data-color="#10b981" style="background-color: #10b981"></button>
|
||||
<button type="button" class="color-preset" data-color="#f59e0b" style="background-color: #f59e0b"></button>
|
||||
<button type="button" class="color-preset" data-color="#8b5cf6" style="background-color: #8b5cf6"></button>
|
||||
<button type="button" class="color-preset" data-color="#ec4899" style="background-color: #ec4899"></button>
|
||||
<button type="button" class="color-preset" data-color="#06b6d4" style="background-color: #06b6d4"></button>
|
||||
<button type="button" class="color-preset" data-color="#84cc16" style="background-color: #84cc16"></button>
|
||||
</div>
|
||||
<div class="color-custom-row">
|
||||
<label class="color-custom-label">Custom:</label>
|
||||
<input type="color" id="tag-color" name="color" value="#3b82f6" class="color-custom-input">
|
||||
</div>
|
||||
<input type="hidden" id="tag-color-hidden" name="color" value="#3b82f6">
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeTagModal()">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="tag-delete-btn" onclick="deleteTag()" style="display: none;">Delete</button>
|
||||
<button type="submit" class="btn btn-primary" id="tag-submit-btn">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Non-authenticated layout (auth pages) -->
|
||||
<div class="auth-layout">
|
||||
{% block auth_content %}{% endblock %}
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<p>© 2025 Firebug IT. All rights reserved.</p>
|
||||
</footer>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
<script src="{% static 'js/app.js' %}?v=3"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,89 @@
|
||||
<!-- Filters Section -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-title">Filters</div>
|
||||
<nav class="sidebar-nav">
|
||||
<a href="{% url 'dashboard' %}?filter=all{% if current_tag_id %}&tag={{ current_tag_id }}{% endif %}"
|
||||
class="sidebar-item {% if current_filter == 'all' or not current_filter %}active{% endif %}">
|
||||
<svg class="sidebar-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 12h18M3 6h18M3 18h18"/>
|
||||
</svg>
|
||||
All Tasks
|
||||
{% if task_counts.all %}<span class="sidebar-item-count">{{ task_counts.all }}</span>{% endif %}
|
||||
</a>
|
||||
<a href="{% url 'dashboard' %}?filter=today{% if current_tag_id %}&tag={{ current_tag_id }}{% endif %}"
|
||||
class="sidebar-item {% if current_filter == 'today' %}active{% endif %}">
|
||||
<svg class="sidebar-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 6v6l4 2"/>
|
||||
</svg>
|
||||
Today
|
||||
{% if task_counts.today %}<span class="sidebar-item-count">{{ task_counts.today }}</span>{% endif %}
|
||||
</a>
|
||||
<a href="{% url 'dashboard' %}?filter=upcoming{% if current_tag_id %}&tag={{ current_tag_id }}{% endif %}"
|
||||
class="sidebar-item {% if current_filter == 'upcoming' %}active{% endif %}">
|
||||
<svg class="sidebar-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<path d="M16 2v4M8 2v4M3 10h18"/>
|
||||
</svg>
|
||||
Upcoming
|
||||
{% if task_counts.upcoming %}<span class="sidebar-item-count">{{ task_counts.upcoming }}</span>{% endif %}
|
||||
</a>
|
||||
<a href="{% url 'dashboard' %}?filter=overdue{% if current_tag_id %}&tag={{ current_tag_id }}{% endif %}"
|
||||
class="sidebar-item {% if current_filter == 'overdue' %}active{% endif %}">
|
||||
<svg class="sidebar-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 8v4M12 16h.01"/>
|
||||
</svg>
|
||||
Overdue
|
||||
{% if task_counts.overdue %}<span class="sidebar-item-count text-danger">{{ task_counts.overdue }}</span>{% endif %}
|
||||
</a>
|
||||
<a href="{% url 'dashboard' %}?filter=completed{% if current_tag_id %}&tag={{ current_tag_id }}{% endif %}"
|
||||
class="sidebar-item {% if current_filter == 'completed' %}active{% endif %}">
|
||||
<svg class="sidebar-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<path d="M22 4L12 14.01l-3-3"/>
|
||||
</svg>
|
||||
Completed
|
||||
{% if task_counts.completed %}<span class="sidebar-item-count">{{ task_counts.completed }}</span>{% endif %}
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tags Section -->
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-title">Tags</div>
|
||||
<nav class="sidebar-nav">
|
||||
{% for tag in tags %}
|
||||
<div class="sidebar-group-row">
|
||||
<a href="{% url 'dashboard' %}?{% if current_filter %}filter={{ current_filter }}&{% endif %}tag={{ tag.id }}"
|
||||
class="sidebar-item sidebar-group-item {% if current_tag_id == tag.id|stringformat:'s' %}active{% endif %}">
|
||||
<span class="group-color-dot" style="background-color: {{ tag.color }}"></span>
|
||||
{{ tag.name }}
|
||||
{% if tag.task_count %}<span class="sidebar-item-count">{{ tag.task_count }}</span>{% endif %}
|
||||
</a>
|
||||
<button type="button" class="sidebar-group-edit" title="Edit tag"
|
||||
onclick="openTagModal('{{ tag.id }}', '{{ tag.name|escapejs }}', '{{ tag.color }}')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="sidebar-item text-muted" style="font-size: var(--font-sm); cursor: default;">
|
||||
No tags yet
|
||||
</div>
|
||||
{% endfor %}
|
||||
<a href="{% url 'dashboard' %}?{% if current_filter %}filter={{ current_filter }}{% endif %}"
|
||||
class="sidebar-item sidebar-group-item {% if not current_tag_id %}active{% endif %}">
|
||||
<span class="group-color-dot" style="background-color: var(--text-muted)"></span>
|
||||
All Tags
|
||||
</a>
|
||||
</nav>
|
||||
<button type="button" class="sidebar-add-btn" onclick="openTagModal()">
|
||||
<svg style="width: 14px; height: 14px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
Add Tag
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,162 @@
|
||||
<!-- Task Detail Panel -->
|
||||
<div class="detail-header">
|
||||
<h2 style="font-size: var(--font-lg); font-weight: 600;">Task Details</h2>
|
||||
<button class="detail-close" onclick="closeDetail()" aria-label="Close detail panel">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'task-detail' task.id %}" id="task-detail-form">
|
||||
{% 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 %}">
|
||||
|
||||
<!-- Title -->
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="detail-title">Title</label>
|
||||
<input type="text" class="form-input" id="detail-title" name="title" value="{{ task.title }}" required>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="detail-description">Description</label>
|
||||
<textarea class="form-textarea" id="detail-description" name="description" rows="3">{{ task.description }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Status & Priority -->
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="detail-status">Status</label>
|
||||
<select class="form-select" id="detail-status" name="status">
|
||||
<option value="pending" {% if task.status == 'pending' %}selected{% endif %}>Pending</option>
|
||||
<option value="in_progress" {% if task.status == 'in_progress' %}selected{% endif %}>In Progress</option>
|
||||
<option value="completed" {% if task.status == 'completed' %}selected{% endif %}>Completed</option>
|
||||
<option value="cancelled" {% if task.status == 'cancelled' %}selected{% endif %}>Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="detail-priority">Priority</label>
|
||||
<select class="form-select" id="detail-priority" name="priority">
|
||||
<option value="low" {% if task.priority == 'low' %}selected{% endif %}>Low</option>
|
||||
<option value="medium" {% if task.priority == 'medium' %}selected{% endif %}>Medium</option>
|
||||
<option value="high" {% if task.priority == 'high' %}selected{% endif %}>High</option>
|
||||
<option value="urgent" {% if task.priority == 'urgent' %}selected{% endif %}>Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Due Date & Time -->
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="detail-due-date">Due Date</label>
|
||||
<input type="date" class="form-input" id="detail-due-date" name="due_date" value="{{ task.due_date|date:'Y-m-d' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="detail-due-time">Due Time</label>
|
||||
<input type="time" class="form-input" id="detail-due-time" name="due_time" value="{{ task.due_time|time:'H:i' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
{% 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 %}
|
||||
|
||||
<!-- Recurrence -->
|
||||
<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) -->
|
||||
<div class="detail-actions" style="margin-bottom: var(--space-lg);">
|
||||
<button type="submit" class="btn btn-primary" style="flex: 1;">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Subtasks Section (OUTSIDE the main form) -->
|
||||
<div style="background: var(--bg); border-radius: var(--radius-sm); padding: var(--space-md); margin-bottom: var(--space-lg);">
|
||||
<div style="font-size: var(--font-sm); font-weight: 500; color: var(--text-secondary); margin-bottom: var(--space-sm);">Subtasks</div>
|
||||
|
||||
<!-- Add subtask form (now separate) -->
|
||||
<form method="post" action="{% url 'subtask-create' task.id %}" style="display: flex; gap: var(--space-sm); margin-bottom: var(--space-sm);">
|
||||
{% 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="text" name="title" class="form-input" placeholder="Add a subtask..." style="flex: 1; padding: var(--space-xs) var(--space-sm); font-size: var(--font-sm);" required>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
||||
</form>
|
||||
|
||||
<!-- Subtask list -->
|
||||
{% if task.subtasks.all %}
|
||||
<div style="display: flex; flex-direction: column; gap: var(--space-xs);">
|
||||
{% for subtask in task.subtasks.all %}
|
||||
<div style="display: flex; align-items: center; gap: var(--space-sm); padding: var(--space-xs); border-radius: var(--radius-xs); background: var(--surface);">
|
||||
<form method="post" action="{% url 'task-toggle' subtask.id %}" style="display: flex;">
|
||||
{% 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 %}">
|
||||
<button type="submit" class="task-checkbox {% if subtask.status == 'completed' %}checked{% endif %}" style="width: 16px; height: 16px; font-size: 10px;">
|
||||
{% if subtask.status == 'completed' %}✓{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<span style="font-size: var(--font-sm); {% if subtask.status == 'completed' %}text-decoration: line-through; color: var(--text-muted);{% endif %}">
|
||||
{{ subtask.title }}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="font-size: var(--font-sm); color: var(--text-muted);">No subtasks</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Time Tracker (OUTSIDE the main form) -->
|
||||
<div class="time-tracker">
|
||||
<div class="time-tracker-header">
|
||||
<span class="time-tracker-label">Time Spent</span>
|
||||
<span class="time-tracker-total">{{ task.total_time_formatted|default:"0:00:00" }}</span>
|
||||
</div>
|
||||
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-sm);">
|
||||
{% if running_timer and running_timer.task_id == task.id %}
|
||||
<form method="post" action="{% url 'timer-stop' task.id %}" style="flex: 1;">
|
||||
{% 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 %}">
|
||||
<button type="submit" class="btn btn-danger btn-full btn-sm">Stop Timer</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{% url 'timer-start' task.id %}" style="flex: 1;">
|
||||
{% 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 %}">
|
||||
<button type="submit" class="btn btn-secondary btn-full btn-sm">Start Timer</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Button (separate form) -->
|
||||
<div class="detail-actions" style="margin-top: var(--space-lg);">
|
||||
<form method="post" action="{% url 'task-delete' task.id %}" onsubmit="return confirm('Delete this task?')">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="next" value="{% url 'dashboard' %}">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Delete Task</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,46 @@
|
||||
<div class="task-item priority-{{ task.priority }} {% if task.is_overdue %}overdue{% endif %} {% if task.status == 'completed' %}completed{% endif %} {% if selected_task_id == task.id|stringformat:'s' %}selected{% endif %}"
|
||||
data-task-id="{{ task.id }}"
|
||||
onclick="selectTask('{{ task.id }}')">
|
||||
|
||||
<!-- Checkbox -->
|
||||
<form method="post" action="{% url 'task-toggle' task.id %}" class="task-toggle" onclick="event.stopPropagation();">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||
<button type="submit" class="task-checkbox {% if task.status == 'completed' %}checked{% endif %}">
|
||||
{% if task.status == 'completed' %}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<path d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="task-content">
|
||||
<div class="task-title">{{ task.title }}</div>
|
||||
<div class="task-meta">
|
||||
{% if task.due_date %}
|
||||
<span class="task-due {% if task.is_overdue %}overdue{% endif %}">
|
||||
📅 {{ task.due_date|date:"M j, Y" }}{% if task.due_time %} 🕐 {{ task.due_time|time:"g:i A" }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if task.due_date and task.tags.all %}
|
||||
<span class="task-meta-separator">·</span>
|
||||
{% endif %}
|
||||
{% for tag in task.tags.all %}
|
||||
<span class="task-group-label" style="background-color: {{ tag.color }}20; color: {{ tag.color }}">
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% if task.total_time_spent > 0 %}
|
||||
{% if task.due_date or task.tags.all %}
|
||||
<span class="task-meta-separator">·</span>
|
||||
{% endif %}
|
||||
<span class="task-time-spent">⏱️ {{ task.total_time_formatted }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Priority Badge -->
|
||||
<span class="priority-badge {{ task.priority }}">{{ task.priority }}</span>
|
||||
</div>
|
||||
@@ -0,0 +1,87 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}{{ page_title|default:"All Tasks" }} - KeepItGoing{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header -->
|
||||
<div class="task-pane-header">
|
||||
<h1 class="task-pane-title">{{ page_title|default:"All Tasks" }}</h1>
|
||||
<div style="display: flex; align-items: center; gap: var(--space-md);">
|
||||
<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);">
|
||||
<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_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_low" {% if current_sort == 'priority_low' %}selected{% endif %}>Priority (Low to High)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Add -->
|
||||
<form method="post" action="{% url 'task-quick-add' %}" class="quick-add">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||
{% if current_tag_id %}
|
||||
<input type="hidden" name="tag" value="{{ current_tag_id }}">
|
||||
{% endif %}
|
||||
<input type="text" name="title" class="quick-add-input" placeholder="Add a new task..." required>
|
||||
<button type="submit" class="btn btn-primary">Add</button>
|
||||
</form>
|
||||
|
||||
<!-- Running Timer -->
|
||||
{% if running_timer %}
|
||||
<div class="time-tracker" style="background-color: var(--accent); color: var(--text-on-accent); margin-bottom: var(--space-lg);">
|
||||
<div class="time-tracker-header">
|
||||
<span class="time-tracker-label" style="color: var(--text-on-accent); opacity: 0.9;">Timer running for: <strong>{{ running_timer.task.title }}</strong></span>
|
||||
<span class="time-tracker-total" id="running-timer" data-started="{{ running_timer.started_at.isoformat }}">00:00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Task List -->
|
||||
{% if tasks %}
|
||||
<div class="task-list" id="task-list">
|
||||
{% for task in tasks %}
|
||||
{% include 'tasks/_task_item.html' %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>No tasks yet.</p>
|
||||
<p class="text-muted">Add one above!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Running timer display
|
||||
const timerDisplay = document.getElementById('running-timer');
|
||||
if (timerDisplay) {
|
||||
const startedAt = new Date(timerDisplay.dataset.started);
|
||||
function updateTimer() {
|
||||
const now = new Date();
|
||||
const diff = Math.floor((now - startedAt) / 1000);
|
||||
const hours = Math.floor(diff / 3600).toString().padStart(2, '0');
|
||||
const minutes = Math.floor((diff % 3600) / 60).toString().padStart(2, '0');
|
||||
const seconds = (diff % 60).toString().padStart(2, '0');
|
||||
timerDisplay.textContent = `${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
updateTimer();
|
||||
setInterval(updateTimer, 1000);
|
||||
}
|
||||
|
||||
// Sort change handler
|
||||
function updateSort() {
|
||||
const sortValue = document.getElementById('sort-select').value;
|
||||
const url = new URL(window.location.href);
|
||||
if (sortValue === 'default') {
|
||||
url.searchParams.delete('sort');
|
||||
} else {
|
||||
url.searchParams.set('sort', sortValue);
|
||||
}
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,61 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Edit {{ tag.name }} - KeepItGoing{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header -->
|
||||
<div class="task-pane-header">
|
||||
<h1 class="task-pane-title">Edit Tag</h1>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-xl); max-width: 500px;">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="next" value="{% url 'tag-list' %}">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="name">Name</label>
|
||||
<input type="text" class="form-input" id="name" name="name" value="{{ tag.name }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="color">Color</label>
|
||||
<div style="display: flex; align-items: center; gap: var(--space-md);">
|
||||
<input type="color" id="color" name="color" value="{{ tag.color|default:'#3B82F6' }}"
|
||||
style="height: 40px; width: 80px; padding: 2px; border: 1px solid var(--border); border-radius: var(--radius-sm); cursor: pointer;">
|
||||
<div style="width: 24px; height: 24px; border-radius: 50%; background: {{ tag.color|default:'#3B82F6' }};" id="colorPreview"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="description">Description</label>
|
||||
<textarea class="form-input" id="description" name="description" rows="3" placeholder="Optional description">{{ tag.description|default:'' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: var(--space-md); margin-top: var(--space-xl);">
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
<a href="{% url 'tag-list' %}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Delete Section -->
|
||||
<div style="margin-top: var(--space-xl); padding-top: var(--space-xl); border-top: 1px solid var(--border);">
|
||||
<h3 style="color: var(--danger); margin-bottom: var(--space-md);">Danger Zone</h3>
|
||||
<p style="color: var(--text-secondary); font-size: var(--font-sm); margin-bottom: var(--space-md);">
|
||||
Deleting this tag will not delete the tasks with it. Tasks will simply lose this tag.
|
||||
</p>
|
||||
<form method="post" action="{% url 'tag-delete' tag.id %}" onsubmit="return confirm('Are you sure you want to delete this tag?');">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="next" value="{% url 'tag-list' %}">
|
||||
<button type="submit" class="btn" style="background: var(--danger); color: white;">Delete Tag</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Update color preview when color picker changes
|
||||
document.getElementById('color').addEventListener('input', function(e) {
|
||||
document.getElementById('colorPreview').style.background = e.target.value;
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Tags - KeepItGoing{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header -->
|
||||
<div class="task-pane-header">
|
||||
<h1 class="task-pane-title">Tags</h1>
|
||||
<span class="task-count">{{ tags|length }} tag{{ tags|length|pluralize }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Create Tag -->
|
||||
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-lg); margin-bottom: var(--space-lg);">
|
||||
<h2 style="font-size: var(--font-lg); margin-bottom: var(--space-md);">Create New Tag</h2>
|
||||
<form method="post" style="display: flex; gap: var(--space-md); flex-wrap: wrap; align-items: flex-end;">
|
||||
{% csrf_token %}
|
||||
<div class="form-group" style="flex: 1; min-width: 200px; margin-bottom: 0;">
|
||||
<label class="form-label" for="name">Name</label>
|
||||
<input type="text" class="form-input" id="name" name="name" required placeholder="Enter tag name">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label class="form-label" for="color">Color</label>
|
||||
<input type="color" id="color" name="color" value="#3B82F6" style="height: 38px; width: 60px; padding: 2px; border: 1px solid var(--border); border-radius: var(--radius-sm); cursor: pointer;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Create Tag</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Tag List -->
|
||||
{% if tags %}
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: var(--space-lg);">
|
||||
{% for tag in tags %}
|
||||
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-lg); border-left: 4px solid {{ tag.color }};">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-sm);">
|
||||
<h3 style="font-size: var(--font-lg); color: {{ tag.color }}; margin: 0;">{{ tag.name }}</h3>
|
||||
{% if tag.is_archived %}
|
||||
<span style="font-size: var(--font-xs); background: var(--text-muted); color: var(--bg); padding: 2px 8px; border-radius: 10px;">Archived</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p style="color: var(--text-secondary); font-size: var(--font-sm); margin-bottom: var(--space-md);">
|
||||
{{ tag.description|default:"No description" }}
|
||||
</p>
|
||||
<div style="display: flex; gap: var(--space-lg); font-size: var(--font-sm); color: var(--text-muted); margin-bottom: var(--space-md);">
|
||||
<span>{{ tag.tasks.count }} tasks</span>
|
||||
</div>
|
||||
<div style="display: flex; gap: var(--space-sm);">
|
||||
<a href="{% url 'dashboard' %}?tag={{ tag.id }}" class="btn btn-secondary btn-sm">View Tasks</a>
|
||||
<a href="{% url 'tag-detail' tag.id %}" class="btn btn-secondary btn-sm" style="cursor: pointer; pointer-events: auto;">Edit</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>No tags created yet.</p>
|
||||
<p class="text-muted">Create your first tag above!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,87 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}New Task - KeepItGoing{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header -->
|
||||
<div class="task-pane-header">
|
||||
<h1 class="task-pane-title">Create New Task</h1>
|
||||
<a href="{% url 'dashboard' %}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-xl); max-width: 600px;">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="title">Title *</label>
|
||||
<input type="text" class="form-input" id="title" name="title" required autofocus placeholder="Enter task title">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="description">Description</label>
|
||||
<textarea class="form-textarea" id="description" name="description" rows="3" placeholder="Add a description..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="status">Status</label>
|
||||
<select class="form-select" id="status" name="status">
|
||||
<option value="pending" selected>Pending</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="priority">Priority</label>
|
||||
<select class="form-select" id="priority" name="priority">
|
||||
<option value="low">Low</option>
|
||||
<option value="medium" selected>Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="due_date">Due Date</label>
|
||||
<input type="date" class="form-input" id="due_date" name="due_date">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="due_time">Due Time</label>
|
||||
<input type="time" class="form-input" id="due_time" name="due_time">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<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 %}
|
||||
<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 }}">
|
||||
<span style="color: {{ tag.color }}">{{ tag.name }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button type="submit" class="btn btn-primary">Create Task</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,151 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}{{ task.title }} - KeepItGoing{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header -->
|
||||
<div class="task-pane-header">
|
||||
<h1 class="task-pane-title">Edit Task</h1>
|
||||
<div style="display: flex; gap: var(--space-sm);">
|
||||
<a href="{% url 'dashboard' %}" class="btn btn-secondary">Back</a>
|
||||
<form method="post" action="{% url 'task-delete' task.id %}" style="display: inline;" onsubmit="return confirm('Delete this task?')">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="next" value="{% url 'dashboard' %}">
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 350px; gap: var(--space-lg);">
|
||||
<!-- Main Form -->
|
||||
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-xl);">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="title">Title</label>
|
||||
<input type="text" class="form-input" id="title" name="title" value="{{ task.title }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="description">Description</label>
|
||||
<textarea class="form-textarea" id="description" name="description" rows="4">{{ task.description }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="status">Status</label>
|
||||
<select class="form-select" id="status" name="status">
|
||||
<option value="pending" {% if task.status == 'pending' %}selected{% endif %}>Pending</option>
|
||||
<option value="in_progress" {% if task.status == 'in_progress' %}selected{% endif %}>In Progress</option>
|
||||
<option value="completed" {% if task.status == 'completed' %}selected{% endif %}>Completed</option>
|
||||
<option value="cancelled" {% if task.status == 'cancelled' %}selected{% endif %}>Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="priority">Priority</label>
|
||||
<select class="form-select" id="priority" name="priority">
|
||||
<option value="low" {% if task.priority == 'low' %}selected{% endif %}>Low</option>
|
||||
<option value="medium" {% if task.priority == 'medium' %}selected{% endif %}>Medium</option>
|
||||
<option value="high" {% if task.priority == 'high' %}selected{% endif %}>High</option>
|
||||
<option value="urgent" {% if task.priority == 'urgent' %}selected{% endif %}>Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="due_date">Due Date</label>
|
||||
<input type="date" class="form-input" id="due_date" name="due_date" value="{{ task.due_date|date:'Y-m-d' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="due_time">Due Time</label>
|
||||
<input type="time" class="form-input" id="due_time" name="due_time" value="{{ task.due_time|time:'H:i' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
{% 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 %}
|
||||
|
||||
<div class="form-group">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar: Time Tracking & Subtasks -->
|
||||
<div>
|
||||
<!-- Time Tracking -->
|
||||
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-lg); margin-bottom: var(--space-lg);">
|
||||
<h2 style="font-size: var(--font-lg); margin-bottom: var(--space-md);">Time Tracking</h2>
|
||||
<div style="font-size: var(--font-xxl); font-weight: 600; font-family: monospace; margin-bottom: var(--space-md);">
|
||||
{{ task.total_time_formatted }}
|
||||
</div>
|
||||
{% if time_entries %}
|
||||
<div style="max-height: 200px; overflow-y: auto;">
|
||||
{% for entry in time_entries %}
|
||||
<div style="display: flex; justify-content: space-between; padding: var(--space-sm); background: var(--bg); border-radius: var(--radius-sm); margin-bottom: var(--space-xs); font-size: var(--font-sm);">
|
||||
<span>{{ entry.started_at|date:"M j, H:i" }}</span>
|
||||
<span>{{ entry.duration_seconds|default:"Running" }}s</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted" style="font-size: var(--font-sm);">No time entries yet</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Subtasks -->
|
||||
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-lg);">
|
||||
<h2 style="font-size: var(--font-lg); margin-bottom: var(--space-md);">Subtasks</h2>
|
||||
{% if task.subtasks.count > 0 %}
|
||||
<div class="task-list">
|
||||
{% for subtask in task.subtasks.all %}
|
||||
{% include 'tasks/_task_item.html' with task=subtask %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted" style="font-size: var(--font-sm);">No subtasks</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,58 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Tasks - KeepItGoing{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Header -->
|
||||
<div class="task-pane-header">
|
||||
<h1 class="task-pane-title">All Tasks</h1>
|
||||
<span class="task-count">{{ tasks|length }} task{{ tasks|length|pluralize }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div style="display: flex; gap: var(--space-sm); flex-wrap: wrap; margin-bottom: var(--space-lg);">
|
||||
<form method="get" style="display: flex; gap: var(--space-sm); flex-wrap: wrap; flex: 1;">
|
||||
<select name="status" class="form-select" onchange="this.form.submit()" style="width: auto;">
|
||||
<option value="">All Status</option>
|
||||
<option value="pending" {% if current_status == 'pending' %}selected{% endif %}>Pending</option>
|
||||
<option value="in_progress" {% if current_status == 'in_progress' %}selected{% endif %}>In Progress</option>
|
||||
<option value="completed" {% if current_status == 'completed' %}selected{% endif %}>Completed</option>
|
||||
<option value="cancelled" {% if current_status == 'cancelled' %}selected{% endif %}>Cancelled</option>
|
||||
</select>
|
||||
|
||||
<select name="priority" class="form-select" onchange="this.form.submit()" style="width: auto;">
|
||||
<option value="">All Priority</option>
|
||||
<option value="urgent" {% if current_priority == 'urgent' %}selected{% endif %}>Urgent</option>
|
||||
<option value="high" {% if current_priority == 'high' %}selected{% endif %}>High</option>
|
||||
<option value="medium" {% if current_priority == 'medium' %}selected{% endif %}>Medium</option>
|
||||
<option value="low" {% if current_priority == 'low' %}selected{% endif %}>Low</option>
|
||||
</select>
|
||||
|
||||
<select name="tag" class="form-select" onchange="this.form.submit()" style="width: auto;">
|
||||
<option value="">All Tags</option>
|
||||
{% for tag in tags %}
|
||||
<option value="{{ tag.id }}" {% if current_tag == tag.id|stringformat:'s' %}selected{% endif %}>
|
||||
{{ tag.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<a href="{% url 'task-list' %}" class="btn btn-secondary btn-sm">Clear Filters</a>
|
||||
</form>
|
||||
<a href="{% url 'task-create' %}" class="btn btn-primary">New Task</a>
|
||||
</div>
|
||||
|
||||
<!-- Task List -->
|
||||
{% if tasks %}
|
||||
<div class="task-list">
|
||||
{% for task in tasks %}
|
||||
{% include 'tasks/_task_item.html' %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>No tasks found</p>
|
||||
<p class="text-muted">Try adjusting your filters or create a new task.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Login - KeepItGoing{% endblock %}
|
||||
|
||||
{% block auth_content %}
|
||||
<div class="auth-card">
|
||||
<h1 class="auth-title">Login</h1>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="email">Email</label>
|
||||
<input type="email" class="form-input" id="email" name="email" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<input type="password" class="form-input" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-full">Login</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-footer">
|
||||
Don't have an account? <a href="{% url 'register' %}">Register</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,77 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Profile - KeepItGoing{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Profile Settings</h1>
|
||||
</div>
|
||||
|
||||
{% if success %}
|
||||
<div class="alert alert-success">{{ success }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="first_name">First Name</label>
|
||||
<input type="text" id="first_name" name="first_name" value="{{ user.first_name }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="last_name">Last Name</label>
|
||||
<input type="text" id="last_name" name="last_name" value="{{ user.last_name }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" value="{{ user.email }}" disabled>
|
||||
<small>Email cannot be changed</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="timezone">Timezone</label>
|
||||
<select id="timezone" name="timezone">
|
||||
<option value="UTC" {% if user.timezone == 'UTC' %}selected{% endif %}>UTC</option>
|
||||
<option value="America/New_York" {% if user.timezone == 'America/New_York' %}selected{% endif %}>Eastern Time</option>
|
||||
<option value="America/Chicago" {% if user.timezone == 'America/Chicago' %}selected{% endif %}>Central Time</option>
|
||||
<option value="America/Denver" {% if user.timezone == 'America/Denver' %}selected{% endif %}>Mountain Time</option>
|
||||
<option value="America/Los_Angeles" {% if user.timezone == 'America/Los_Angeles' %}selected{% endif %}>Pacific Time</option>
|
||||
<option value="Europe/London" {% if user.timezone == 'Europe/London' %}selected{% endif %}>London</option>
|
||||
<option value="Europe/Paris" {% if user.timezone == 'Europe/Paris' %}selected{% endif %}>Paris</option>
|
||||
<option value="Asia/Tokyo" {% if user.timezone == 'Asia/Tokyo' %}selected{% endif %}>Tokyo</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="default_reminder_minutes">Default Reminder (minutes before due)</label>
|
||||
<select id="default_reminder_minutes" name="default_reminder_minutes">
|
||||
<option value="0" {% if user.default_reminder_minutes == 0 %}selected{% endif %}>No reminder</option>
|
||||
<option value="5" {% if user.default_reminder_minutes == 5 %}selected{% endif %}>5 minutes</option>
|
||||
<option value="15" {% if user.default_reminder_minutes == 15 %}selected{% endif %}>15 minutes</option>
|
||||
<option value="30" {% if user.default_reminder_minutes == 30 %}selected{% endif %}>30 minutes</option>
|
||||
<option value="60" {% if user.default_reminder_minutes == 60 %}selected{% endif %}>1 hour</option>
|
||||
<option value="1440" {% if user.default_reminder_minutes == 1440 %}selected{% endif %}>1 day</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="email_notifications" {% if user.email_notifications %}checked{% endif %}>
|
||||
Email Notifications
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="push_notifications" {% if user.push_notifications %}checked{% endif %}>
|
||||
Push Notifications
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,42 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Register - KeepItGoing{% endblock %}
|
||||
|
||||
{% block auth_content %}
|
||||
<div class="auth-card">
|
||||
<h1 class="auth-title">Create Account</h1>
|
||||
|
||||
{% if errors %}
|
||||
<div class="alert alert-error">
|
||||
{% for field, error in errors.items %}
|
||||
<p>{{ error }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="email">Email</label>
|
||||
<input type="email" class="form-input" id="email" name="email" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="username">Username</label>
|
||||
<input type="text" class="form-input" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="password">Password</label>
|
||||
<input type="password" class="form-input" id="password" name="password" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="password_confirm">Confirm Password</label>
|
||||
<input type="password" class="form-input" id="password_confirm" name="password_confirm" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-full">Register</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-footer">
|
||||
Already have an account? <a href="{% url 'login' %}">Login</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
User admin configuration.
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
from .models import User, DeviceToken
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(BaseUserAdmin):
|
||||
"""Admin configuration for User model."""
|
||||
|
||||
list_display = ['email', 'username', 'first_name', 'last_name', 'is_staff', 'created_at']
|
||||
list_filter = ['is_staff', 'is_superuser', 'is_active', 'created_at']
|
||||
search_fields = ['email', 'username', 'first_name', 'last_name']
|
||||
ordering = ['-created_at']
|
||||
|
||||
fieldsets = BaseUserAdmin.fieldsets + (
|
||||
('Profile', {'fields': ('timezone', 'avatar')}),
|
||||
('Preferences', {'fields': ('default_reminder_minutes', 'email_notifications', 'push_notifications')}),
|
||||
)
|
||||
|
||||
add_fieldsets = (
|
||||
(None, {
|
||||
'classes': ('wide',),
|
||||
'fields': ('email', 'username', 'password1', 'password2'),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(DeviceToken)
|
||||
class DeviceTokenAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for DeviceToken model."""
|
||||
|
||||
list_display = ['user', 'platform', 'device_name', 'is_active', 'created_at']
|
||||
list_filter = ['platform', 'is_active']
|
||||
search_fields = ['user__email', 'device_name']
|
||||
ordering = ['-created_at']
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
name = 'users'
|
||||
@@ -0,0 +1,71 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-10 00:05
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('email', models.EmailField(max_length=254, unique=True)),
|
||||
('timezone', models.CharField(default='UTC', max_length=50)),
|
||||
('avatar', models.ImageField(blank=True, null=True, upload_to='avatars/')),
|
||||
('default_reminder_minutes', models.IntegerField(default=30)),
|
||||
('email_notifications', models.BooleanField(default=True)),
|
||||
('push_notifications', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'db_table': 'users',
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DeviceToken',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('platform', models.CharField(choices=[('android', 'Android'), ('web', 'Web'), ('desktop', 'Desktop')], max_length=20)),
|
||||
('token', models.TextField()),
|
||||
('device_name', models.CharField(blank=True, max_length=255)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='device_tokens', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'device_tokens',
|
||||
'unique_together': {('user', 'token')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
User models for KeepItGoing.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
"""
|
||||
Custom user model for KeepItGoing.
|
||||
|
||||
Uses email as the primary identifier instead of username.
|
||||
"""
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
email = models.EmailField(unique=True)
|
||||
|
||||
# Profile fields
|
||||
timezone = models.CharField(max_length=50, default='UTC')
|
||||
avatar = models.ImageField(upload_to='avatars/', null=True, blank=True)
|
||||
|
||||
# Preferences
|
||||
default_reminder_minutes = models.IntegerField(default=30)
|
||||
email_notifications = models.BooleanField(default=True)
|
||||
push_notifications = models.BooleanField(default=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
# Use email as the username field
|
||||
USERNAME_FIELD = 'email'
|
||||
REQUIRED_FIELDS = ['username']
|
||||
|
||||
class Meta:
|
||||
db_table = 'users'
|
||||
verbose_name = 'user'
|
||||
verbose_name_plural = 'users'
|
||||
|
||||
def __str__(self):
|
||||
return self.email
|
||||
|
||||
|
||||
class DeviceToken(models.Model):
|
||||
"""
|
||||
Stores push notification tokens for user devices.
|
||||
"""
|
||||
PLATFORM_CHOICES = [
|
||||
('android', 'Android'),
|
||||
('web', 'Web'),
|
||||
('desktop', 'Desktop'),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='device_tokens')
|
||||
platform = models.CharField(max_length=20, choices=PLATFORM_CHOICES)
|
||||
token = models.TextField()
|
||||
device_name = models.CharField(max_length=255, blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'device_tokens'
|
||||
unique_together = ['user', 'token']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email} - {self.platform} - {self.device_name}"
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
User serializers for KeepItGoing API.
|
||||
"""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from rest_framework import serializers
|
||||
from .models import DeviceToken
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user details."""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'id', 'email', 'username', 'first_name', 'last_name',
|
||||
'timezone', 'avatar', 'default_reminder_minutes',
|
||||
'email_notifications', 'push_notifications',
|
||||
'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['id', 'email', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class UserRegistrationSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user registration."""
|
||||
|
||||
password = serializers.CharField(
|
||||
write_only=True,
|
||||
required=True,
|
||||
validators=[validate_password],
|
||||
style={'input_type': 'password'}
|
||||
)
|
||||
password_confirm = serializers.CharField(
|
||||
write_only=True,
|
||||
required=True,
|
||||
style={'input_type': 'password'}
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['email', 'username', 'password', 'password_confirm', 'first_name', 'last_name']
|
||||
|
||||
def validate(self, attrs):
|
||||
if attrs['password'] != attrs['password_confirm']:
|
||||
raise serializers.ValidationError({
|
||||
'password_confirm': "Passwords do not match."
|
||||
})
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data.pop('password_confirm')
|
||||
user = User.objects.create_user(**validated_data)
|
||||
return user
|
||||
|
||||
|
||||
class ChangePasswordSerializer(serializers.Serializer):
|
||||
"""Serializer for password change."""
|
||||
|
||||
old_password = serializers.CharField(
|
||||
required=True,
|
||||
style={'input_type': 'password'}
|
||||
)
|
||||
new_password = serializers.CharField(
|
||||
required=True,
|
||||
validators=[validate_password],
|
||||
style={'input_type': 'password'}
|
||||
)
|
||||
|
||||
def validate_old_password(self, value):
|
||||
user = self.context['request'].user
|
||||
if not user.check_password(value):
|
||||
raise serializers.ValidationError("Current password is incorrect.")
|
||||
return value
|
||||
|
||||
|
||||
class DeviceTokenSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for device tokens."""
|
||||
|
||||
class Meta:
|
||||
model = DeviceToken
|
||||
fields = ['id', 'platform', 'token', 'device_name', 'is_active', 'created_at']
|
||||
read_only_fields = ['id', 'created_at']
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['user'] = self.context['request'].user
|
||||
# Update if token already exists, otherwise create
|
||||
device_token, created = DeviceToken.objects.update_or_create(
|
||||
user=validated_data['user'],
|
||||
token=validated_data['token'],
|
||||
defaults=validated_data
|
||||
)
|
||||
return device_token
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
User URLs for KeepItGoing.
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
from rest_framework_simplejwt.views import (
|
||||
TokenObtainPairView,
|
||||
TokenRefreshView,
|
||||
)
|
||||
|
||||
from . import views
|
||||
|
||||
# API URLs (to be included under /api/users/)
|
||||
api_urlpatterns = [
|
||||
path('register/', views.RegisterAPIView.as_view(), name='api-register'),
|
||||
path('profile/', views.UserProfileAPIView.as_view(), name='api-profile'),
|
||||
path('change-password/', views.ChangePasswordAPIView.as_view(), name='api-change-password'),
|
||||
path('devices/', views.DeviceTokenAPIView.as_view(), name='api-devices'),
|
||||
path('devices/<uuid:pk>/', views.DeviceTokenDeleteAPIView.as_view(), name='api-device-delete'),
|
||||
path('token/', TokenObtainPairView.as_view(), name='token-obtain-pair'),
|
||||
path('token/refresh/', TokenRefreshView.as_view(), name='token-refresh'),
|
||||
]
|
||||
|
||||
# Web URLs (to be included at root level)
|
||||
web_urlpatterns = [
|
||||
path('login/', views.LoginView.as_view(), name='login'),
|
||||
path('logout/', views.LogoutView.as_view(), name='logout'),
|
||||
path('register/', views.RegisterView.as_view(), name='register'),
|
||||
path('profile/', views.ProfileView.as_view(), name='profile'),
|
||||
]
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
User views for KeepItGoing.
|
||||
"""
|
||||
|
||||
from django.contrib.auth import get_user_model, login, logout
|
||||
from django.shortcuts import render, redirect
|
||||
from django.views import View
|
||||
from rest_framework import generics, permissions, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from .serializers import (
|
||||
UserSerializer,
|
||||
UserRegistrationSerializer,
|
||||
ChangePasswordSerializer,
|
||||
DeviceTokenSerializer,
|
||||
)
|
||||
from .models import DeviceToken
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API Views
|
||||
# =============================================================================
|
||||
|
||||
class RegisterAPIView(generics.CreateAPIView):
|
||||
"""API endpoint for user registration."""
|
||||
|
||||
queryset = User.objects.all()
|
||||
serializer_class = UserRegistrationSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
user = serializer.save()
|
||||
|
||||
# Generate tokens
|
||||
refresh = RefreshToken.for_user(user)
|
||||
|
||||
return Response({
|
||||
'user': UserSerializer(user).data,
|
||||
'tokens': {
|
||||
'refresh': str(refresh),
|
||||
'access': str(refresh.access_token),
|
||||
}
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class UserProfileAPIView(generics.RetrieveUpdateAPIView):
|
||||
"""API endpoint for user profile."""
|
||||
|
||||
serializer_class = UserSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_object(self):
|
||||
return self.request.user
|
||||
|
||||
|
||||
class ChangePasswordAPIView(APIView):
|
||||
"""API endpoint for changing password."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
serializer = ChangePasswordSerializer(
|
||||
data=request.data,
|
||||
context={'request': request}
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
request.user.set_password(serializer.validated_data['new_password'])
|
||||
request.user.save()
|
||||
|
||||
return Response({'message': 'Password changed successfully.'})
|
||||
|
||||
|
||||
class DeviceTokenAPIView(generics.ListCreateAPIView):
|
||||
"""API endpoint for managing device tokens."""
|
||||
|
||||
serializer_class = DeviceTokenSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return DeviceToken.objects.filter(user=self.request.user)
|
||||
|
||||
|
||||
class DeviceTokenDeleteAPIView(generics.DestroyAPIView):
|
||||
"""API endpoint for deleting a device token."""
|
||||
|
||||
serializer_class = DeviceTokenSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return DeviceToken.objects.filter(user=self.request.user)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Web Views (Django Templates)
|
||||
# =============================================================================
|
||||
|
||||
class LoginView(View):
|
||||
"""Web login page."""
|
||||
|
||||
def get(self, request):
|
||||
if request.user.is_authenticated:
|
||||
return redirect('dashboard')
|
||||
return render(request, 'users/login.html')
|
||||
|
||||
def post(self, request):
|
||||
from django.contrib.auth import authenticate
|
||||
|
||||
email = request.POST.get('email')
|
||||
password = request.POST.get('password')
|
||||
|
||||
user = authenticate(request, username=email, password=password)
|
||||
|
||||
if user is not None:
|
||||
login(request, user)
|
||||
next_url = request.GET.get('next', 'dashboard')
|
||||
return redirect(next_url)
|
||||
|
||||
return render(request, 'users/login.html', {
|
||||
'error': 'Invalid email or password.'
|
||||
})
|
||||
|
||||
|
||||
class LogoutView(View):
|
||||
"""Web logout."""
|
||||
|
||||
def get(self, request):
|
||||
logout(request)
|
||||
return redirect('login')
|
||||
|
||||
def post(self, request):
|
||||
logout(request)
|
||||
return redirect('login')
|
||||
|
||||
|
||||
class RegisterView(View):
|
||||
"""Web registration page."""
|
||||
|
||||
def get(self, request):
|
||||
if request.user.is_authenticated:
|
||||
return redirect('dashboard')
|
||||
return render(request, 'users/register.html')
|
||||
|
||||
def post(self, request):
|
||||
email = request.POST.get('email')
|
||||
username = request.POST.get('username')
|
||||
password = request.POST.get('password')
|
||||
password_confirm = request.POST.get('password_confirm')
|
||||
|
||||
errors = {}
|
||||
|
||||
if password != password_confirm:
|
||||
errors['password_confirm'] = 'Passwords do not match.'
|
||||
|
||||
if User.objects.filter(email=email).exists():
|
||||
errors['email'] = 'Email already registered.'
|
||||
|
||||
if User.objects.filter(username=username).exists():
|
||||
errors['username'] = 'Username already taken.'
|
||||
|
||||
if errors:
|
||||
return render(request, 'users/register.html', {'errors': errors})
|
||||
|
||||
user = User.objects.create_user(
|
||||
email=email,
|
||||
username=username,
|
||||
password=password
|
||||
)
|
||||
login(request, user)
|
||||
return redirect('dashboard')
|
||||
|
||||
|
||||
class ProfileView(View):
|
||||
"""Web profile page."""
|
||||
|
||||
def get(self, request):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect('login')
|
||||
return render(request, 'users/profile.html')
|
||||
|
||||
def post(self, request):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect('login')
|
||||
|
||||
user = request.user
|
||||
user.first_name = request.POST.get('first_name', '')
|
||||
user.last_name = request.POST.get('last_name', '')
|
||||
user.timezone = request.POST.get('timezone', 'UTC')
|
||||
user.default_reminder_minutes = int(request.POST.get('default_reminder_minutes', 30))
|
||||
user.email_notifications = request.POST.get('email_notifications') == 'on'
|
||||
user.push_notifications = request.POST.get('push_notifications') == 'on'
|
||||
user.save()
|
||||
|
||||
return render(request, 'users/profile.html', {
|
||||
'success': 'Profile updated successfully.'
|
||||
})
|
||||
Reference in New Issue
Block a user