commit 6a0b35c39ca350a9ba5710dd6a4be9dc71ad9313 Author: Keith Smith Date: Thu Dec 18 17:41:29 2025 -0700 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e0953ab --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1273c7 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cf5633e --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..2970e73 --- /dev/null +++ b/config/__init__.py @@ -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',) diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..ffbb5f5 --- /dev/null +++ b/config/asgi.py @@ -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() diff --git a/config/celery.py b/config/celery.py new file mode 100644 index 0000000..f1687b2 --- /dev/null +++ b/config/celery.py @@ -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}') diff --git a/config/settings/__init__.py b/config/settings/__init__.py new file mode 100644 index 0000000..e2893b1 --- /dev/null +++ b/config/settings/__init__.py @@ -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 * diff --git a/config/settings/base.py b/config/settings/base.py new file mode 100644 index 0000000..1ee4f32 --- /dev/null +++ b/config/settings/base.py @@ -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/' diff --git a/config/settings/development.py b/config/settings/development.py new file mode 100644 index 0000000..64ff093 --- /dev/null +++ b/config/settings/development.py @@ -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, + }, + }, +} diff --git a/config/settings/production.py b/config/settings/production.py new file mode 100644 index 0000000..f972aa9 --- /dev/null +++ b/config/settings/production.py @@ -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, + }, + }, +} diff --git a/config/settings/selfhosted.py b/config/settings/selfhosted.py new file mode 100644 index 0000000..781b7da --- /dev/null +++ b/config/settings/selfhosted.py @@ -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', + }, +} diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..bb2be53 --- /dev/null +++ b/config/urls.py @@ -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) diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..4ced574 --- /dev/null +++ b/config/wsgi.py @@ -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() diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..27a6b64 --- /dev/null +++ b/docker-compose.dev.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ae18860 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..8e7ac79 --- /dev/null +++ b/manage.py @@ -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() diff --git a/notifications/__init__.py b/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/admin.py b/notifications/admin.py new file mode 100644 index 0000000..45a75bc --- /dev/null +++ b/notifications/admin.py @@ -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'] diff --git a/notifications/apps.py b/notifications/apps.py new file mode 100644 index 0000000..9c260e0 --- /dev/null +++ b/notifications/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + name = 'notifications' diff --git a/notifications/migrations/0001_initial.py b/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..ff47925 --- /dev/null +++ b/notifications/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/notifications/migrations/0002_initial.py b/notifications/migrations/0002_initial.py new file mode 100644 index 0000000..7fd7542 --- /dev/null +++ b/notifications/migrations/0002_initial.py @@ -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'), + ), + ] diff --git a/notifications/migrations/0003_initial.py b/notifications/migrations/0003_initial.py new file mode 100644 index 0000000..65ddab4 --- /dev/null +++ b/notifications/migrations/0003_initial.py @@ -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'), + ), + ] diff --git a/notifications/migrations/__init__.py b/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/models.py b/notifications/models.py new file mode 100644 index 0000000..c7116be --- /dev/null +++ b/notifications/models.py @@ -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}" diff --git a/notifications/serializers.py b/notifications/serializers.py new file mode 100644 index 0000000..aa623dd --- /dev/null +++ b/notifications/serializers.py @@ -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'] diff --git a/notifications/tasks.py b/notifications/tasks.py new file mode 100644 index 0000000..744c546 --- /dev/null +++ b/notifications/tasks.py @@ -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 + ) diff --git a/notifications/tests.py b/notifications/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/notifications/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/notifications/urls.py b/notifications/urls.py new file mode 100644 index 0000000..795ac62 --- /dev/null +++ b/notifications/urls.py @@ -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('/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('/', views.delete_notification, name='api-notification-delete'), +] diff --git a/notifications/views.py b/notifications/views.py new file mode 100644 index 0000000..6972972 --- /dev/null +++ b/notifications/views.py @@ -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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..47b4c19 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/static/css/app.css b/static/css/app.css new file mode 100644 index 0000000..2ae770b --- /dev/null +++ b/static/css/app.css @@ -0,0 +1,1028 @@ +/* KeepItGoing - Material Design Stylesheet */ +/* Matches desktop QML Theme.qml */ + +/* ============================================ + CSS Variables (Theme) + ============================================ */ +:root { + /* Light theme colors (default) */ + --bg: #ffffff; + --surface: #f9fafb; + --surface-hover: #f3f4f6; + --surface-selected: #dbeafe; + --text-primary: #111827; + --text-secondary: #6b7280; + --text-muted: #9ca3af; + --text-on-accent: #ffffff; + --border: #e5e7eb; + --border-light: #f3f4f6; + + /* Brand/Accent */ + --accent: #3b82f6; + --accent-hover: #2563eb; + --accent-pressed: #1d4ed8; + --accent-light: #dbeafe; + + /* Semantic */ + --success: #10b981; + --success-hover: #059669; + --warning: #f59e0b; + --warning-hover: #d97706; + --danger: #ef4444; + --danger-hover: #dc2626; + + /* Priority colors */ + --priority-urgent: #ef4444; + --priority-high: #f59e0b; + --priority-medium: #3b82f6; + --priority-low: #6b7280; + + /* Spacing */ + --space-xs: 4px; + --space-sm: 8px; + --space-md: 12px; + --space-lg: 16px; + --space-xl: 24px; + --space-xxl: 32px; + + /* Border radius */ + --radius-xs: 2px; + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + + /* Font sizes */ + --font-xs: 10px; + --font-sm: 12px; + --font-md: 14px; + --font-lg: 16px; + --font-xl: 18px; + --font-xxl: 20px; + --font-title: 24px; + + /* Layout sizes */ + --sidebar-width: 220px; + --detail-panel-width: 350px; + --header-height: 56px; + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); + + /* Transitions */ + --anim-fast: 100ms; + --anim-normal: 150ms; + --anim-slow: 250ms; +} + +/* Dark theme */ +[data-theme="dark"] { + --bg: #1f2937; + --surface: #374151; + --surface-hover: #4b5563; + --surface-selected: #1e40af; + --text-primary: #f9fafb; + --text-secondary: #9ca3af; + --text-muted: #6b7280; + --border: #4b5563; + --border-light: #374151; + --accent-light: #1e3a5f; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3); +} + +/* ============================================ + Reset & Base + ============================================ */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + font-size: var(--font-md); + line-height: 1.5; + color: var(--text-primary); + background-color: var(--bg); +} + +a { + color: var(--accent); + text-decoration: none; + transition: color var(--anim-fast); +} + +a:hover { + color: var(--accent-hover); +} + +/* ============================================ + App Layout (3-pane) + ============================================ */ +.app-layout { + display: grid; + grid-template-rows: var(--header-height) 1fr; + grid-template-columns: var(--sidebar-width) 1fr var(--detail-panel-width); + height: 100vh; + overflow: hidden; +} + +.app-layout.detail-closed { + grid-template-columns: var(--sidebar-width) 1fr 0; +} + +/* Header spans full width */ +.app-header { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--space-lg); + background-color: var(--surface); + border-bottom: 1px solid var(--border); + z-index: 100; +} + +.app-brand { + font-size: var(--font-xl); + font-weight: 700; + color: var(--accent); +} + +.app-header-actions { + display: flex; + align-items: center; + gap: var(--space-md); +} + +.header-user { + display: flex; + align-items: center; + gap: var(--space-sm); + color: var(--text-secondary); + font-size: var(--font-sm); +} + +/* Theme toggle button */ +.theme-toggle { + background: none; + border: none; + padding: var(--space-sm); + cursor: pointer; + color: var(--text-secondary); + border-radius: var(--radius-sm); + transition: background-color var(--anim-fast), color var(--anim-fast); +} + +.theme-toggle:hover { + background-color: var(--surface-hover); + color: var(--text-primary); +} + +.theme-toggle svg { + width: 20px; + height: 20px; +} + +/* ============================================ + Sidebar + ============================================ */ +.app-sidebar { + background-color: var(--surface); + border-right: 1px solid var(--border); + padding: var(--space-lg); + overflow-y: auto; +} + +.sidebar-section { + margin-bottom: var(--space-xl); +} + +.sidebar-section-title { + font-size: var(--font-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-bottom: var(--space-sm); +} + +.sidebar-nav { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.sidebar-item { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: var(--font-md); + cursor: pointer; + transition: background-color var(--anim-fast), color var(--anim-fast); +} + +.sidebar-item:hover { + background-color: var(--surface-hover); + color: var(--text-primary); +} + +.sidebar-item.active { + background-color: var(--accent-light); + color: var(--accent); + font-weight: 500; +} + +.sidebar-item-icon { + width: 16px; + height: 16px; + opacity: 0.7; +} + +.sidebar-item-count { + margin-left: auto; + font-size: var(--font-xs); + color: var(--text-muted); + background-color: var(--border-light); + padding: 2px 6px; + border-radius: 10px; +} + +/* Group items with color indicator */ +.sidebar-group-item { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.group-color-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +/* Group row with edit button */ +.sidebar-group-row { + display: flex; + align-items: center; + gap: var(--space-xs); +} + +.sidebar-group-row .sidebar-group-item { + flex: 1; + min-width: 0; +} + +.sidebar-group-edit { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: var(--space-xs); + color: var(--text-muted); + background: none; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background-color var(--anim-fast), color var(--anim-fast); + flex-shrink: 0; +} + +.sidebar-group-edit:hover { + background-color: var(--surface-hover); + color: var(--accent); +} + +.sidebar-group-edit svg { + width: 14px; + height: 14px; +} + +.sidebar-add-btn { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + color: var(--text-muted); + font-size: var(--font-sm); + font-family: inherit; + cursor: pointer; + border: none; + background: none; + border-radius: var(--radius-sm); + transition: background-color var(--anim-fast), color var(--anim-fast); + width: 100%; + text-align: left; +} + +.sidebar-add-btn:hover { + background-color: var(--surface-hover); + color: var(--accent); +} + +/* ============================================ + Task List (center pane) + ============================================ */ +.task-pane { + background-color: var(--bg); + padding: var(--space-lg); + overflow-y: auto; +} + +.task-pane-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-lg); +} + +.task-pane-title { + font-size: var(--font-xxl); + font-weight: 700; + color: var(--text-primary); +} + +.task-count { + font-size: var(--font-md); + color: var(--text-secondary); +} + +/* Quick Add */ +.quick-add { + display: flex; + gap: var(--space-sm); + margin-bottom: var(--space-lg); +} + +.quick-add-input { + flex: 1; + padding: var(--space-sm) var(--space-md); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: var(--font-md); + background-color: var(--surface); + color: var(--text-primary); + transition: border-color var(--anim-fast), box-shadow var(--anim-fast); +} + +.quick-add-input::placeholder { + color: var(--text-muted); +} + +.quick-add-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +/* Task List */ +.task-list { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +/* Task Item */ +.task-item { + display: flex; + align-items: center; + gap: var(--space-md); + padding: var(--space-md); + background-color: var(--surface); + border-radius: var(--radius-sm); + border: 1px solid var(--border); + cursor: pointer; + transition: background-color var(--anim-fast), border-color var(--anim-fast); + position: relative; +} + +.task-item:hover { + background-color: var(--surface-hover); +} + +.task-item.selected { + background-color: var(--surface-selected); + border-color: var(--accent); +} + +.task-item.overdue { + background-color: rgba(239, 68, 68, 0.1); +} + +/* Priority bar on left */ +.task-item::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + border-radius: var(--radius-sm) 0 0 var(--radius-sm); + background-color: var(--priority-low); +} + +.task-item.priority-urgent::before { background-color: var(--priority-urgent); } +.task-item.priority-high::before { background-color: var(--priority-high); } +.task-item.priority-medium::before { background-color: var(--priority-medium); } +.task-item.priority-low::before { background-color: var(--priority-low); } + +/* Checkbox */ +.task-checkbox { + width: 20px; + height: 20px; + border: 2px solid var(--border); + border-radius: var(--radius-xs); + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background-color var(--anim-fast), border-color var(--anim-fast); +} + +.task-checkbox:hover { + border-color: var(--accent); +} + +.task-checkbox.checked { + background-color: var(--accent); + border-color: var(--accent); + color: var(--text-on-accent); +} + +.task-checkbox svg { + width: 12px; + height: 12px; +} + +/* Task content */ +.task-content { + flex: 1; + min-width: 0; +} + +.task-title { + font-size: var(--font-md); + font-weight: 500; + color: var(--text-primary); + margin-bottom: var(--space-xs); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.task-item.completed .task-title { + text-decoration: line-through; + color: var(--text-muted); +} + +.task-meta { + display: flex; + align-items: center; + gap: var(--space-sm); + font-size: var(--font-sm); +} + +.task-due { + color: var(--text-secondary); +} + +.task-due.overdue { + color: var(--danger); + font-weight: 500; +} + +.task-group-label { + padding: 2px var(--space-sm); + border-radius: 10px; + font-size: var(--font-xs); +} + +.task-meta-separator { + color: var(--text-muted); +} + +/* Priority badge */ +.priority-badge { + padding: var(--space-xs) var(--space-sm); + border-radius: var(--radius-xs); + font-size: var(--font-xs); + font-weight: 600; + text-transform: uppercase; +} + +.priority-badge.urgent { + background-color: rgba(239, 68, 68, 0.2); + color: var(--priority-urgent); +} + +.priority-badge.high { + background-color: rgba(245, 158, 11, 0.2); + color: var(--priority-high); +} + +.priority-badge.medium { + background-color: rgba(59, 130, 246, 0.2); + color: var(--priority-medium); +} + +.priority-badge.low { + background-color: rgba(107, 114, 128, 0.2); + color: var(--priority-low); +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: var(--space-xxl); + color: var(--text-muted); +} + +/* ============================================ + Task Detail Panel (right pane) + ============================================ */ +.detail-pane { + background-color: var(--surface); + border-left: 1px solid var(--border); + padding: var(--space-lg); + overflow-y: auto; + transition: width var(--anim-normal); +} + +.detail-pane.hidden { + display: none; +} + +.detail-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-lg); +} + +.detail-close { + background: none; + border: none; + padding: var(--space-sm); + cursor: pointer; + color: var(--text-secondary); + border-radius: var(--radius-sm); + transition: background-color var(--anim-fast); +} + +.detail-close:hover { + background-color: var(--surface-hover); +} + +/* Form elements */ +.form-group { + margin-bottom: var(--space-lg); +} + +.form-label { + display: block; + font-size: var(--font-sm); + font-weight: 500; + color: var(--text-secondary); + margin-bottom: var(--space-xs); +} + +.form-input, +.form-select, +.form-textarea { + width: 100%; + padding: var(--space-sm) var(--space-md); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: var(--font-md); + background-color: var(--bg); + color: var(--text-primary); + transition: border-color var(--anim-fast), box-shadow var(--anim-fast); +} + +.form-input:focus, +.form-select:focus, +.form-textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.form-textarea { + resize: vertical; + min-height: 100px; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-md); +} + +/* Time tracker */ +.time-tracker { + background-color: var(--bg); + border-radius: var(--radius-sm); + padding: var(--space-md); + margin-bottom: var(--space-lg); +} + +.time-tracker-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-sm); +} + +.time-tracker-label { + font-size: var(--font-sm); + color: var(--text-secondary); +} + +.time-tracker-total { + font-size: var(--font-lg); + font-weight: 600; + font-family: monospace; + color: var(--text-primary); +} + +/* Detail actions */ +.detail-actions { + display: flex; + gap: var(--space-sm); + margin-top: var(--space-xl); +} + +/* ============================================ + Buttons + ============================================ */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-lg); + border-radius: var(--radius-sm); + font-size: var(--font-md); + font-weight: 500; + cursor: pointer; + border: none; + transition: background-color var(--anim-fast), color var(--anim-fast); +} + +.btn-primary { + background-color: var(--accent); + color: var(--text-on-accent); +} + +.btn-primary:hover { + background-color: var(--accent-hover); +} + +.btn-secondary { + background-color: var(--surface-hover); + color: var(--text-primary); + border: 1px solid var(--border); +} + +.btn-secondary:hover { + background-color: var(--border-light); +} + +.btn-danger { + background-color: var(--danger); + color: var(--text-on-accent); +} + +.btn-danger:hover { + background-color: var(--danger-hover); +} + +.btn-sm { + padding: var(--space-xs) var(--space-sm); + font-size: var(--font-sm); +} + +.btn-full { + width: 100%; +} + +/* ============================================ + Alerts/Messages + ============================================ */ +.alert { + padding: var(--space-md); + border-radius: var(--radius-sm); + margin-bottom: var(--space-md); +} + +.alert-success { + background-color: rgba(16, 185, 129, 0.1); + color: var(--success); + border: 1px solid var(--success); +} + +.alert-error { + background-color: rgba(239, 68, 68, 0.1); + color: var(--danger); + border: 1px solid var(--danger); +} + +/* ============================================ + Auth Pages + ============================================ */ +.auth-layout { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + background-color: var(--bg); +} + +.auth-card { + background-color: var(--surface); + border-radius: var(--radius-lg); + padding: var(--space-xxl); + width: 100%; + max-width: 400px; + box-shadow: var(--shadow-md); +} + +.auth-title { + font-size: var(--font-title); + font-weight: 700; + text-align: center; + margin-bottom: var(--space-xl); + color: var(--text-primary); +} + +.auth-footer { + text-align: center; + margin-top: var(--space-xl); + color: var(--text-secondary); + font-size: var(--font-sm); +} + +/* ============================================ + Dialogs/Modals + ============================================ */ +.modal-backdrop { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: opacity var(--anim-normal), visibility var(--anim-normal); +} + +.modal-backdrop.open { + opacity: 1; + visibility: visible; +} + +.modal { + background-color: var(--surface); + border-radius: var(--radius-lg); + padding: var(--space-xl); + width: 100%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; + transform: scale(0.95); + transition: transform var(--anim-normal); +} + +.modal-backdrop.open .modal { + transform: scale(1); +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-lg); +} + +.modal-title { + font-size: var(--font-xl); + font-weight: 600; +} + +.modal-close { + background: none; + border: none; + padding: var(--space-sm); + cursor: pointer; + color: var(--text-secondary); +} + +.modal-actions { + display: flex; + gap: var(--space-sm); + justify-content: flex-end; + margin-top: var(--space-xl); +} + +/* ============================================ + Footer + ============================================ */ +.app-footer { + display: none; /* Hidden in app layout */ +} + +/* Footer for auth pages */ +.auth-layout .footer { + position: fixed; + bottom: var(--space-lg); + left: 0; + right: 0; + text-align: center; + color: var(--text-muted); + font-size: var(--font-sm); +} + +/* ============================================ + Mobile Responsive + ============================================ */ +@media (max-width: 1024px) { + .app-layout { + grid-template-columns: var(--sidebar-width) 1fr; + } + + .detail-pane { + position: fixed; + right: 0; + top: var(--header-height); + bottom: 0; + width: var(--detail-panel-width); + z-index: 50; + transform: translateX(100%); + transition: transform var(--anim-normal); + } + + .detail-pane.open { + transform: translateX(0); + } +} + +@media (max-width: 768px) { + :root { + --sidebar-width: 0; + } + + .app-layout { + grid-template-columns: 1fr; + } + + .app-sidebar { + position: fixed; + left: 0; + top: var(--header-height); + bottom: 0; + width: 260px; + z-index: 50; + transform: translateX(-100%); + transition: transform var(--anim-normal); + } + + .app-sidebar.open { + transform: translateX(0); + } + + .sidebar-toggle { + display: flex; + } + + .detail-pane { + width: 100%; + } + + .form-row { + grid-template-columns: 1fr; + } +} + +/* Sidebar toggle button (visible on mobile) */ +.sidebar-toggle { + display: none; + background: none; + border: none; + padding: var(--space-sm); + cursor: pointer; + color: var(--text-secondary); +} + +.sidebar-toggle svg { + width: 24px; + height: 24px; +} + +/* Mobile overlay */ +.mobile-overlay { + display: none; + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: 40; +} + +.mobile-overlay.visible { + display: block; +} + +/* ============================================ + Color Presets (Group Modal) + ============================================ */ +.color-presets { + display: flex; + gap: var(--space-sm); + flex-wrap: wrap; + margin-bottom: var(--space-md); +} + +.color-preset { + width: 32px; + height: 32px; + border-radius: 50%; + border: 3px solid transparent; + cursor: pointer; + transition: border-color var(--anim-fast), transform var(--anim-fast); +} + +.color-preset:hover { + transform: scale(1.1); +} + +.color-preset.selected { + border-color: var(--text-primary); +} + +.color-custom-row { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.color-custom-label { + font-size: var(--font-sm); + color: var(--text-secondary); +} + +.color-custom-input { + width: 48px; + height: 32px; + padding: 2px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; + background: var(--bg); +} + +/* Group Preview */ +.group-preview { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-md); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + +.group-preview-dot { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent); +} + +.group-preview-name { + font-size: var(--font-md); + color: var(--text-primary); +} + +/* ============================================ + Utility Classes + ============================================ */ +.hidden { display: none !important; } +.text-muted { color: var(--text-muted); } +.text-danger { color: var(--danger); } +.text-success { color: var(--success); } +.mt-lg { margin-top: var(--space-lg); } +.mb-lg { margin-bottom: var(--space-lg); } diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..bd1fe91 --- /dev/null +++ b/static/css/style.css @@ -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; + } +} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..a8ff0df --- /dev/null +++ b/static/js/app.js @@ -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 = '

Loading...

'; + 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 = ` +
+

Failed to load task details

+

Please refresh the page

+
+ `; + }); +} + +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.'); + }); +}; diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..1e42529 --- /dev/null +++ b/static/js/main.js @@ -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(); + } +} diff --git a/sync/__init__.py b/sync/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sync/admin.py b/sync/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/sync/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/sync/apps.py b/sync/apps.py new file mode 100644 index 0000000..0d95b48 --- /dev/null +++ b/sync/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class SyncConfig(AppConfig): + name = 'sync' diff --git a/sync/migrations/0001_initial.py b/sync/migrations/0001_initial.py new file mode 100644 index 0000000..080d53d --- /dev/null +++ b/sync/migrations/0001_initial.py @@ -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', + }, + ), + ] diff --git a/sync/migrations/0002_initial.py b/sync/migrations/0002_initial.py new file mode 100644 index 0000000..9fc08e4 --- /dev/null +++ b/sync/migrations/0002_initial.py @@ -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')}, + ), + ] diff --git a/sync/migrations/__init__.py b/sync/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sync/models.py b/sync/models.py new file mode 100644 index 0000000..8bcbf49 --- /dev/null +++ b/sync/models.py @@ -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}" diff --git a/sync/serializers.py b/sync/serializers.py new file mode 100644 index 0000000..6193220 --- /dev/null +++ b/sync/serializers.py @@ -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'] diff --git a/sync/tests.py b/sync/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/sync/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/sync/urls.py b/sync/urls.py new file mode 100644 index 0000000..76d6e16 --- /dev/null +++ b/sync/urls.py @@ -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//resolve/', views.resolve_conflict, name='api-sync-resolve'), +] diff --git a/sync/views.py b/sync/views.py new file mode 100644 index 0000000..5d3bd1a --- /dev/null +++ b/sync/views.py @@ -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 diff --git a/tasks/__init__.py b/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tasks/admin.py b/tasks/admin.py new file mode 100644 index 0000000..3c45241 --- /dev/null +++ b/tasks/admin.py @@ -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'] diff --git a/tasks/apps.py b/tasks/apps.py new file mode 100644 index 0000000..2054722 --- /dev/null +++ b/tasks/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class TasksConfig(AppConfig): + name = 'tasks' diff --git a/tasks/migrations/0001_initial.py b/tasks/migrations/0001_initial.py new file mode 100644 index 0000000..81aba43 --- /dev/null +++ b/tasks/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/tasks/migrations/0002_initial.py b/tasks/migrations/0002_initial.py new file mode 100644 index 0000000..8dd7b3d --- /dev/null +++ b/tasks/migrations/0002_initial.py @@ -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'), + ), + ] diff --git a/tasks/migrations/0003_merge_groups_to_tags.py b/tasks/migrations/0003_merge_groups_to_tags.py new file mode 100644 index 0000000..edd10ce --- /dev/null +++ b/tasks/migrations/0003_merge_groups_to_tags.py @@ -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', + ), + ] diff --git a/tasks/migrations/0004_alter_tag_color_alter_tag_name_alter_taskshare_tag.py b/tasks/migrations/0004_alter_tag_color_alter_tag_name_alter_taskshare_tag.py new file mode 100644 index 0000000..ce7e221 --- /dev/null +++ b/tasks/migrations/0004_alter_tag_color_alter_tag_name_alter_taskshare_tag.py @@ -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'), + ), + ] diff --git a/tasks/migrations/0005_fix_tasks_tags_table.py b/tasks/migrations/0005_fix_tasks_tags_table.py new file mode 100644 index 0000000..2c2d3f0 --- /dev/null +++ b/tasks/migrations/0005_fix_tasks_tags_table.py @@ -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), + ] diff --git a/tasks/migrations/0006_fix_task_shares_table.py b/tasks/migrations/0006_fix_task_shares_table.py new file mode 100644 index 0000000..b18bece --- /dev/null +++ b/tasks/migrations/0006_fix_task_shares_table.py @@ -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), + ] diff --git a/tasks/migrations/0007_tag_is_deleted_task_is_deleted_timeentry_is_deleted.py b/tasks/migrations/0007_tag_is_deleted_task_is_deleted_timeentry_is_deleted.py new file mode 100644 index 0000000..30309a0 --- /dev/null +++ b/tasks/migrations/0007_tag_is_deleted_task_is_deleted_timeentry_is_deleted.py @@ -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), + ), + ] diff --git a/tasks/migrations/__init__.py b/tasks/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tasks/models.py b/tasks/models.py new file mode 100644 index 0000000..9f616c9 --- /dev/null +++ b/tasks/models.py @@ -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}" diff --git a/tasks/serializers.py b/tasks/serializers.py new file mode 100644 index 0000000..caa7784 --- /dev/null +++ b/tasks/serializers.py @@ -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) diff --git a/tasks/tests.py b/tasks/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/tasks/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/tasks/urls.py b/tasks/urls.py new file mode 100644 index 0000000..3d941d9 --- /dev/null +++ b/tasks/urls.py @@ -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('/', views.TaskDetailAPIView.as_view(), name='api-task-detail'), + path('tags/', views.TagListCreateAPIView.as_view(), name='api-tag-list'), + path('tags//', views.TagDetailAPIView.as_view(), name='api-tag-detail'), + path('time-entries/', views.TimeEntryListCreateAPIView.as_view(), name='api-time-entry-list'), + path('time-entries//', views.TimeEntryDetailAPIView.as_view(), name='api-time-entry-detail'), + path('/start-timer/', views.start_timer, name='api-start-timer'), + path('time-entries//stop/', views.stop_timer, name='api-stop-timer'), + path('shares/', views.TaskShareListCreateAPIView.as_view(), name='api-share-list'), + path('shares//', 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//', views.TaskDetailView.as_view(), name='task-detail'), + path('tasks//detail-partial/', views.task_detail_partial, name='task-detail-partial'), + path('tasks/quick-add/', views.task_quick_add, name='task-quick-add'), + path('tasks//toggle/', views.task_toggle_status, name='task-toggle'), + path('tasks//delete/', views.task_delete, name='task-delete'), + path('tasks//subtask/', views.subtask_create, name='subtask-create'), + path('tasks//timer/start/', views.web_timer_start, name='timer-start'), + path('tasks//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//', views.TagDetailView.as_view(), name='tag-detail'), + path('tags//delete/', views.tag_delete, name='tag-delete'), +] diff --git a/tasks/views.py b/tasks/views.py new file mode 100644 index 0000000..fcc07c0 --- /dev/null +++ b/tasks/views.py @@ -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) diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..52a5b32 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,140 @@ +{% load static %} + + + + + + {% block title %}KeepItGoing{% endblock %} + + {% block extra_css %}{% endblock %} + + + {% block body %} + {% if user.is_authenticated %} +
+ +
+
+ + KeepItGoing +
+ +
+ +
+ {{ user.email }} + Logout +
+
+
+ + + + + +
+ {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + + {% block content %}{% endblock %} +
+ + + + + +
+
+ + + + {% else %} + +
+ {% block auth_content %}{% endblock %} +
+
+

© 2025 Firebug IT. All rights reserved.

+
+ {% endif %} + {% endblock %} + + + {% block extra_js %}{% endblock %} + + diff --git a/templates/tasks/_sidebar.html b/templates/tasks/_sidebar.html new file mode 100644 index 0000000..42b3cfd --- /dev/null +++ b/templates/tasks/_sidebar.html @@ -0,0 +1,89 @@ + + + + + diff --git a/templates/tasks/_task_detail.html b/templates/tasks/_task_detail.html new file mode 100644 index 0000000..aa31028 --- /dev/null +++ b/templates/tasks/_task_detail.html @@ -0,0 +1,162 @@ + +
+

Task Details

+ +
+ +
+ {% csrf_token %} + + + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + + {% if tags %} +
+ +
+ {% for tag in tags %} + + {% endfor %} +
+
+ {% endif %} + + +
+ + +
+ + +
+ +
+
+ + +
+
Subtasks
+ + +
+ {% csrf_token %} + + + +
+ + + {% if task.subtasks.all %} +
+ {% for subtask in task.subtasks.all %} +
+
+ {% csrf_token %} + + +
+ + {{ subtask.title }} + +
+ {% endfor %} +
+ {% else %} +
No subtasks
+ {% endif %} +
+ + +
+
+ Time Spent + {{ task.total_time_formatted|default:"0:00:00" }} +
+
+ {% if running_timer and running_timer.task_id == task.id %} +
+ {% csrf_token %} + + +
+ {% else %} +
+ {% csrf_token %} + + +
+ {% endif %} +
+
+ + +
+
+ {% csrf_token %} + + +
+
diff --git a/templates/tasks/_task_item.html b/templates/tasks/_task_item.html new file mode 100644 index 0000000..4166816 --- /dev/null +++ b/templates/tasks/_task_item.html @@ -0,0 +1,46 @@ +
+ + +
+ {% csrf_token %} + + +
+ + +
+
{{ task.title }}
+
+ {% if task.due_date %} + + ๐Ÿ“… {{ task.due_date|date:"M j, Y" }}{% if task.due_time %} ๐Ÿ• {{ task.due_time|time:"g:i A" }}{% endif %} + + {% endif %} + {% if task.due_date and task.tags.all %} + ยท + {% endif %} + {% for tag in task.tags.all %} + + {{ tag.name }} + + {% endfor %} + {% if task.total_time_spent > 0 %} + {% if task.due_date or task.tags.all %} + ยท + {% endif %} + โฑ๏ธ {{ task.total_time_formatted }} + {% endif %} +
+
+ + + {{ task.priority }} +
diff --git a/templates/tasks/dashboard.html b/templates/tasks/dashboard.html new file mode 100644 index 0000000..d02daa3 --- /dev/null +++ b/templates/tasks/dashboard.html @@ -0,0 +1,87 @@ +{% extends 'base.html' %} + +{% block title %}{{ page_title|default:"All Tasks" }} - KeepItGoing{% endblock %} + +{% block content %} + +
+

{{ page_title|default:"All Tasks" }}

+
+ {{ tasks|length }} task{{ tasks|length|pluralize }} + +
+
+ + +
+ {% csrf_token %} + + {% if current_tag_id %} + + {% endif %} + + +
+ + +{% if running_timer %} +
+
+ Timer running for: {{ running_timer.task.title }} + 00:00:00 +
+
+{% endif %} + + +{% if tasks %} +
+ {% for task in tasks %} + {% include 'tasks/_task_item.html' %} + {% endfor %} +
+{% else %} +
+

No tasks yet.

+

Add one above!

+
+{% endif %} +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/tasks/tag_edit.html b/templates/tasks/tag_edit.html new file mode 100644 index 0000000..448fee6 --- /dev/null +++ b/templates/tasks/tag_edit.html @@ -0,0 +1,61 @@ +{% extends 'base.html' %} + +{% block title %}Edit {{ tag.name }} - KeepItGoing{% endblock %} + +{% block content %} + +
+

Edit Tag

+
+ +
+
+ {% csrf_token %} + + +
+ + +
+ +
+ +
+ +
+
+
+ +
+ + +
+ +
+ + Cancel +
+
+ + +
+

Danger Zone

+

+ Deleting this tag will not delete the tasks with it. Tasks will simply lose this tag. +

+
+ {% csrf_token %} + + +
+
+
+ + +{% endblock %} diff --git a/templates/tasks/tag_list.html b/templates/tasks/tag_list.html new file mode 100644 index 0000000..034d86e --- /dev/null +++ b/templates/tasks/tag_list.html @@ -0,0 +1,59 @@ +{% extends 'base.html' %} + +{% block title %}Tags - KeepItGoing{% endblock %} + +{% block content %} + +
+

Tags

+ {{ tags|length }} tag{{ tags|length|pluralize }} +
+ + +
+

Create New Tag

+
+ {% csrf_token %} +
+ + +
+
+ + +
+ +
+
+ + +{% if tags %} +
+ {% for tag in tags %} +
+
+

{{ tag.name }}

+ {% if tag.is_archived %} + Archived + {% endif %} +
+

+ {{ tag.description|default:"No description" }} +

+
+ {{ tag.tasks.count }} tasks +
+ +
+ {% endfor %} +
+{% else %} +
+

No tags created yet.

+

Create your first tag above!

+
+{% endif %} +{% endblock %} diff --git a/templates/tasks/task_create.html b/templates/tasks/task_create.html new file mode 100644 index 0000000..e5dccb6 --- /dev/null +++ b/templates/tasks/task_create.html @@ -0,0 +1,87 @@ +{% extends 'base.html' %} + +{% block title %}New Task - KeepItGoing{% endblock %} + +{% block content %} + +
+

Create New Task

+ Cancel +
+ +
+
+ {% csrf_token %} + +
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ + {% if tags %} +
+ +
+ {% for tag in tags %} + + {% endfor %} +
+
+ {% endif %} + + +
+
+{% endblock %} diff --git a/templates/tasks/task_detail.html b/templates/tasks/task_detail.html new file mode 100644 index 0000000..37378dd --- /dev/null +++ b/templates/tasks/task_detail.html @@ -0,0 +1,151 @@ +{% extends 'base.html' %} + +{% block title %}{{ task.title }} - KeepItGoing{% endblock %} + +{% block content %} + +
+

Edit Task

+
+ Back +
+ {% csrf_token %} + + +
+
+
+ +
+ +
+
+ {% csrf_token %} + +
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ + + {% if tags %} +
+ +
+ {% for tag in tags %} + + {% endfor %} +
+
+ {% endif %} + +
+ + +
+ + {% if tags %} +
+ +
+ {% for tag in tags %} + + {% endfor %} +
+
+ {% endif %} + + +
+
+ + +
+ +
+

Time Tracking

+
+ {{ task.total_time_formatted }} +
+ {% if time_entries %} +
+ {% for entry in time_entries %} +
+ {{ entry.started_at|date:"M j, H:i" }} + {{ entry.duration_seconds|default:"Running" }}s +
+ {% endfor %} +
+ {% else %} +

No time entries yet

+ {% endif %} +
+ + +
+

Subtasks

+ {% if task.subtasks.count > 0 %} +
+ {% for subtask in task.subtasks.all %} + {% include 'tasks/_task_item.html' with task=subtask %} + {% endfor %} +
+ {% else %} +

No subtasks

+ {% endif %} +
+
+
+{% endblock %} diff --git a/templates/tasks/task_list.html b/templates/tasks/task_list.html new file mode 100644 index 0000000..5ba1e07 --- /dev/null +++ b/templates/tasks/task_list.html @@ -0,0 +1,58 @@ +{% extends 'base.html' %} + +{% block title %}Tasks - KeepItGoing{% endblock %} + +{% block content %} + +
+

All Tasks

+ {{ tasks|length }} task{{ tasks|length|pluralize }} +
+ + +
+
+ + + + + + + Clear Filters +
+ New Task +
+ + +{% if tasks %} +
+ {% for task in tasks %} + {% include 'tasks/_task_item.html' %} + {% endfor %} +
+{% else %} +
+

No tasks found

+

Try adjusting your filters or create a new task.

+
+{% endif %} +{% endblock %} diff --git a/templates/users/login.html b/templates/users/login.html new file mode 100644 index 0000000..d76945d --- /dev/null +++ b/templates/users/login.html @@ -0,0 +1,30 @@ +{% extends 'base.html' %} + +{% block title %}Login - KeepItGoing{% endblock %} + +{% block auth_content %} +
+

Login

+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+ {% csrf_token %} +
+ + +
+
+ + +
+ +
+ + +
+{% endblock %} diff --git a/templates/users/profile.html b/templates/users/profile.html new file mode 100644 index 0000000..3d77b1f --- /dev/null +++ b/templates/users/profile.html @@ -0,0 +1,77 @@ +{% extends 'base.html' %} + +{% block title %}Profile - KeepItGoing{% endblock %} + +{% block content %} + + +{% if success %} +
{{ success }}
+{% endif %} + +
+
+ {% csrf_token %} +
+
+ + +
+
+ + +
+
+ +
+ + + Email cannot be changed +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ + +
+
+{% endblock %} diff --git a/templates/users/register.html b/templates/users/register.html new file mode 100644 index 0000000..126a3f1 --- /dev/null +++ b/templates/users/register.html @@ -0,0 +1,42 @@ +{% extends 'base.html' %} + +{% block title %}Register - KeepItGoing{% endblock %} + +{% block auth_content %} +
+

Create Account

+ + {% if errors %} +
+ {% for field, error in errors.items %} +

{{ error }}

+ {% endfor %} +
+ {% endif %} + +
+ {% csrf_token %} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+{% endblock %} diff --git a/users/__init__.py b/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/admin.py b/users/admin.py new file mode 100644 index 0000000..4b15440 --- /dev/null +++ b/users/admin.py @@ -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'] diff --git a/users/apps.py b/users/apps.py new file mode 100644 index 0000000..4ce1fab --- /dev/null +++ b/users/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + name = 'users' diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py new file mode 100644 index 0000000..53eccac --- /dev/null +++ b/users/migrations/0001_initial.py @@ -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')}, + }, + ), + ] diff --git a/users/migrations/__init__.py b/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/models.py b/users/models.py new file mode 100644 index 0000000..92a3dfb --- /dev/null +++ b/users/models.py @@ -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}" diff --git a/users/serializers.py b/users/serializers.py new file mode 100644 index 0000000..4aa03e2 --- /dev/null +++ b/users/serializers.py @@ -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 diff --git a/users/tests.py b/users/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/users/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/users/urls.py b/users/urls.py new file mode 100644 index 0000000..b03f46e --- /dev/null +++ b/users/urls.py @@ -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//', 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'), +] diff --git a/users/views.py b/users/views.py new file mode 100644 index 0000000..bfb5cc4 --- /dev/null +++ b/users/views.py @@ -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.' + })