Initial commit: KeepItGoing task management server

Features:
- Django-based REST API with web interface
- Task management with tags, priorities, and due dates
- Time tracking with start/stop timers
- Subtasks support
- Task filtering (all, today, upcoming, overdue, completed)
- Tag-based organization with color coding
- Sorting by due date and priority
- Auto-assign tags when filtering
- Responsive 3-pane layout (sidebar, task list, detail panel)
- Task sharing between users
- Mobile-responsive design with dark mode support

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Keith Smith
2025-12-18 17:41:29 -07:00
co-authored by Claude Sonnet 4.5
commit 6a0b35c39c
84 changed files with 7763 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# Django settings
DEBUG=True
SECRET_KEY=your-secret-key-change-in-production
ALLOWED_HOSTS=localhost,127.0.0.1
# Database
DATABASE_URL=postgres://keepitgoing:keepitgoing@localhost:5432/keepitgoing
# Redis (for Celery and caching)
REDIS_URL=redis://localhost:6379/0
# CORS (comma-separated origins)
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
# SaaS mode (enables billing and usage limits)
SAAS_MODE=False
# Push notifications
FCM_CREDENTIALS_PATH=
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_ADMIN_EMAIL=
# Email (for password reset, etc.)
EMAIL_HOST=
EMAIL_PORT=587
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
EMAIL_USE_TLS=True
DEFAULT_FROM_EMAIL=noreply@keepitgoing.app
+41
View File
@@ -0,0 +1,41 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
.venv/
ENV/
# Django
*.log
local_settings.py
db.sqlite3
media/
# Environment
.env
*.env.local
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Static files (collected)
staticfiles/
# Coverage
htmlcov/
.coverage
.coverage.*
# Firebase credentials
*firebase*.json
+36
View File
@@ -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"]
+9
View File
@@ -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',)
+16
View File
@@ -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()
+35
View File
@@ -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}')
+11
View File
@@ -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 *
+149
View File
@@ -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/'
+69
View File
@@ -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,
},
},
}
+93
View File
@@ -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,
},
},
}
+83
View File
@@ -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',
},
}
+33
View File
@@ -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)
+16
View File
@@ -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()
+37
View File
@@ -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:
+94
View File
@@ -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:
Executable
+22
View File
@@ -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()
View File
+28
View File
@@ -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']
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class NotificationsConfig(AppConfig):
name = 'notifications'
+45
View File
@@ -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'],
},
),
]
+22
View File
@@ -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'),
),
]
+29
View File
@@ -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'),
),
]
+70
View File
@@ -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}"
+29
View File
@@ -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']
+235
View File
@@ -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
)
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+15
View File
@@ -0,0 +1,15 @@
"""
Notification URLs for KeepItGoing.
"""
from django.urls import path
from . import views
# API URLs (to be included under /api/notifications/)
api_urlpatterns = [
path('', views.NotificationListAPIView.as_view(), name='api-notification-list'),
path('<uuid:notification_id>/read/', views.mark_read, name='api-notification-read'),
path('read-all/', views.mark_all_read, name='api-notification-read-all'),
path('unread-count/', views.unread_count, name='api-notification-unread-count'),
path('<uuid:notification_id>/', views.delete_notification, name='api-notification-delete'),
]
+96
View File
@@ -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)
+30
View File
@@ -0,0 +1,30 @@
# Django core
Django>=5.0,<6.0
django-environ>=0.11.0
django-cors-headers>=4.3.0
django-filter>=24.0
# Django REST Framework
djangorestframework>=3.14.0
djangorestframework-simplejwt>=5.3.0
# Database
psycopg2-binary>=2.9.9
dj-database-url>=2.1.0
# Task queue
celery>=5.3.0
redis>=5.0.0
django-celery-beat>=2.5.0
# Push notifications
firebase-admin>=6.4.0
pywebpush>=1.14.0
# Utilities
python-dateutil>=2.8.2
Pillow>=10.0.0
# Production
gunicorn>=21.0.0
whitenoise>=6.6.0
+1028
View File
File diff suppressed because it is too large Load Diff
+549
View File
@@ -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;
}
}
+710
View File
@@ -0,0 +1,710 @@
/**
* KeepItGoing - Main Application JavaScript
* Handles theme toggle, task selection, mobile menus, and AJAX operations
*/
document.addEventListener('DOMContentLoaded', function() {
initTheme();
initTaskSelection();
initTimerDisplays();
});
/* ============================================
Theme Toggle
============================================ */
function initTheme() {
// Check for saved theme preference or system preference
const savedTheme = localStorage.getItem('theme');
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedTheme) {
setTheme(savedTheme);
} else if (systemDark) {
setTheme('dark');
}
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
if (!localStorage.getItem('theme')) {
setTheme(e.matches ? 'dark' : 'light');
}
});
}
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
}
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
// Update theme toggle icon
const lightIcon = document.getElementById('theme-icon-light');
const darkIcon = document.getElementById('theme-icon-dark');
if (lightIcon && darkIcon) {
if (theme === 'dark') {
lightIcon.style.display = 'none';
darkIcon.style.display = 'block';
} else {
lightIcon.style.display = 'block';
darkIcon.style.display = 'none';
}
}
}
/* ============================================
Task Selection
============================================ */
function initTaskSelection() {
// Add click handlers to task items
const taskItems = document.querySelectorAll('.task-item');
taskItems.forEach(item => {
item.addEventListener('click', function(e) {
// Don't select if clicking checkbox form
if (e.target.closest('.task-toggle')) {
return;
}
const taskId = this.dataset.taskId;
if (taskId) {
selectTask(taskId);
}
});
});
}
function selectTask(taskId) {
// Update URL without full page reload
const url = new URL(window.location);
url.searchParams.set('selected', taskId);
window.history.pushState({}, '', url);
// Update visual selection
document.querySelectorAll('.task-item').forEach(item => {
item.classList.remove('selected');
});
const selectedItem = document.querySelector(`.task-item[data-task-id="${taskId}"]`);
if (selectedItem) {
selectedItem.classList.add('selected');
}
// Load task detail via AJAX
loadTaskDetail(taskId);
}
function loadTaskDetail(taskId) {
const detailPane = document.getElementById('detail-pane');
const appLayout = document.getElementById('app');
if (!detailPane) return;
// Show loading state
detailPane.innerHTML = '<div class="empty-state"><p>Loading...</p></div>';
detailPane.classList.remove('hidden');
if (appLayout) {
appLayout.classList.remove('detail-closed');
}
// On mobile, show detail pane
if (window.innerWidth <= 1024) {
detailPane.classList.add('open');
document.getElementById('mobile-overlay').classList.add('visible');
}
// Fetch task detail HTML
fetch(`/tasks/${taskId}/detail-partial/`)
.then(response => {
if (!response.ok) throw new Error('Failed to load task');
return response.text();
})
.then(html => {
detailPane.innerHTML = html;
})
.catch(error => {
console.error('Error loading task detail:', error);
detailPane.innerHTML = `
<div class="empty-state">
<p class="text-danger">Failed to load task details</p>
<p class="text-muted">Please refresh the page</p>
</div>
`;
});
}
function closeDetail() {
const detailPane = document.getElementById('detail-pane');
const appLayout = document.getElementById('app');
if (detailPane) {
detailPane.classList.add('hidden');
detailPane.classList.remove('open');
}
if (appLayout) {
appLayout.classList.add('detail-closed');
}
// Clear selection
document.querySelectorAll('.task-item').forEach(item => {
item.classList.remove('selected');
});
// Update URL
const url = new URL(window.location);
url.searchParams.delete('selected');
window.history.pushState({}, '', url);
// Close mobile overlay
closeMobileMenus();
}
/* ============================================
Mobile Navigation
============================================ */
function toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('mobile-overlay');
if (sidebar) {
sidebar.classList.toggle('open');
}
if (overlay) {
overlay.classList.toggle('visible', sidebar.classList.contains('open'));
}
}
function closeMobileMenus() {
const sidebar = document.getElementById('sidebar');
const detailPane = document.getElementById('detail-pane');
const overlay = document.getElementById('mobile-overlay');
if (sidebar) {
sidebar.classList.remove('open');
}
if (detailPane) {
detailPane.classList.remove('open');
}
if (overlay) {
overlay.classList.remove('visible');
}
}
/* ============================================
Timer Displays
============================================ */
function initTimerDisplays() {
// Handle running timer in header/dashboard
const runningTimer = document.getElementById('running-timer');
if (runningTimer) {
const startedAt = new Date(runningTimer.dataset.started);
updateTimerDisplay(runningTimer, startedAt);
setInterval(() => updateTimerDisplay(runningTimer, startedAt), 1000);
}
}
function updateTimerDisplay(element, startedAt) {
const now = new Date();
const diff = Math.floor((now - startedAt) / 1000);
const hours = Math.floor(diff / 3600).toString().padStart(2, '0');
const minutes = Math.floor((diff % 3600) / 60).toString().padStart(2, '0');
const seconds = (diff % 60).toString().padStart(2, '0');
element.textContent = `${hours}:${minutes}:${seconds}`;
}
/* ============================================
Utility Functions
============================================ */
function formatDuration(seconds) {
if (seconds < 60) {
return `${seconds}s`;
} else if (seconds < 3600) {
const mins = Math.floor(seconds / 60);
return `${mins}m`;
} else {
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
return `${hours}h ${mins}m`;
}
}
function formatRelativeDate(dateString) {
const date = new Date(dateString);
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const dateOnly = new Date(date);
dateOnly.setHours(0, 0, 0, 0);
if (dateOnly.getTime() === today.getTime()) {
return 'Today';
} else if (dateOnly.getTime() === tomorrow.getTime()) {
return 'Tomorrow';
} else if (dateOnly.getTime() === yesterday.getTime()) {
return 'Yesterday';
} else {
return date.toLocaleDateString();
}
}
/* ============================================
Handle Browser Back/Forward
============================================ */
window.addEventListener('popstate', function(e) {
const url = new URL(window.location);
const selectedTaskId = url.searchParams.get('selected');
if (selectedTaskId) {
// Show the selected task
document.querySelectorAll('.task-item').forEach(item => {
item.classList.remove('selected');
});
const selectedItem = document.querySelector(`.task-item[data-task-id="${selectedTaskId}"]`);
if (selectedItem) {
selectedItem.classList.add('selected');
}
loadTaskDetail(selectedTaskId);
} else {
// Close detail panel
closeDetail();
}
});
/* ============================================
Group Modal
============================================ */
let currentGroupId = null;
let selectedColor = '#3b82f6';
document.addEventListener('DOMContentLoaded', function() {
initGroupModal();
});
function initGroupModal() {
// Setup backdrop click to close
const backdrop = document.getElementById('group-modal-backdrop');
if (backdrop) {
backdrop.addEventListener('click', function(e) {
if (e.target === backdrop) {
closeGroupModal();
}
});
}
// Setup color preset buttons
document.querySelectorAll('.color-preset').forEach(btn => {
btn.addEventListener('click', function() {
selectColor(this.dataset.color);
});
});
// Setup custom color picker
const colorPicker = document.getElementById('group-color');
if (colorPicker) {
colorPicker.addEventListener('input', function() {
selectColor(this.value, true);
});
}
// Setup name field for preview
const nameField = document.getElementById('group-name');
if (nameField) {
nameField.addEventListener('input', updateGroupPreview);
}
// Setup form submission
const form = document.getElementById('group-form');
if (form) {
form.addEventListener('submit', handleGroupSubmit);
}
}
// Make openGroupModal globally available for onclick handlers
window.openGroupModal = function(groupId = null, name = '', color = '#3b82f6') {
console.log('openGroupModal called:', { groupId, name, color });
const backdrop = document.getElementById('group-modal-backdrop');
const title = document.getElementById('group-modal-title');
const nameField = document.getElementById('group-name');
const deleteBtn = document.getElementById('group-delete-btn');
const submitBtn = document.getElementById('group-submit-btn');
const groupIdField = document.getElementById('group-id');
if (!backdrop) {
console.error('Modal backdrop not found!');
return;
}
currentGroupId = groupId;
if (groupId) {
// Edit mode
if (title) title.textContent = 'Edit Group';
if (nameField) nameField.value = name;
if (deleteBtn) deleteBtn.style.display = 'inline-flex';
if (submitBtn) submitBtn.textContent = 'Save';
if (groupIdField) groupIdField.value = groupId;
} else {
// Create mode
if (title) title.textContent = 'New Group';
if (nameField) nameField.value = '';
if (deleteBtn) deleteBtn.style.display = 'none';
if (submitBtn) submitBtn.textContent = 'Create';
if (groupIdField) groupIdField.value = '';
color = '#3b82f6';
}
selectColor(color);
updateGroupPreview();
backdrop.classList.add('open');
console.log('Modal should now be open');
if (nameField) nameField.focus();
};
// Make closeGroupModal globally available for onclick handlers
window.closeGroupModal = function() {
const backdrop = document.getElementById('group-modal-backdrop');
if (backdrop) {
backdrop.classList.remove('open');
}
currentGroupId = null;
};
function selectColor(color, isCustom = false) {
selectedColor = color;
// Update preset button selection
document.querySelectorAll('.color-preset').forEach(btn => {
btn.classList.toggle('selected', btn.dataset.color.toLowerCase() === color.toLowerCase());
});
// Update custom color picker if not already from custom
if (!isCustom) {
const colorPicker = document.getElementById('group-color');
if (colorPicker) {
colorPicker.value = color;
}
}
// Update hidden field
const hiddenField = document.getElementById('group-color-hidden');
if (hiddenField) {
hiddenField.value = color;
}
// Update preview
updateGroupPreview();
}
function updateGroupPreview() {
const nameField = document.getElementById('group-name');
const previewDot = document.getElementById('group-preview-dot');
const previewName = document.getElementById('group-preview-name');
if (previewDot) {
previewDot.style.backgroundColor = selectedColor;
}
if (previewName && nameField) {
previewName.textContent = nameField.value || 'Group Preview';
previewName.style.color = nameField.value ? 'var(--text-primary)' : 'var(--text-muted)';
}
}
function handleGroupSubmit(e) {
e.preventDefault();
const nameField = document.getElementById('group-name');
const name = nameField.value.trim();
if (!name) {
nameField.focus();
return;
}
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
const formData = new FormData();
formData.append('name', name);
formData.append('color', selectedColor);
formData.append('csrfmiddlewaretoken', csrfToken);
let url, method;
if (currentGroupId) {
// Update existing group
url = `/groups/${currentGroupId}/`;
formData.append('next', window.location.pathname + window.location.search);
} else {
// Create new group
url = '/groups/';
formData.append('next', window.location.pathname + window.location.search);
}
fetch(url, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => {
if (response.redirected) {
window.location.href = response.url;
} else if (response.ok) {
window.location.reload();
} else {
throw new Error('Failed to save group');
}
})
.catch(error => {
console.error('Error saving group:', error);
alert('Failed to save group. Please try again.');
});
}
// Make deleteGroup globally available for onclick handlers
window.deleteGroup = function() {
if (!currentGroupId) return;
if (!confirm('Are you sure you want to delete this group?\n\nTasks in this group will not be deleted.')) {
return;
}
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
const formData = new FormData();
formData.append('csrfmiddlewaretoken', csrfToken);
formData.append('next', window.location.pathname + window.location.search);
fetch(`/groups/${currentGroupId}/delete/`, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => {
if (response.redirected) {
window.location.href = response.url;
} else if (response.ok) {
window.location.reload();
} else {
throw new Error('Failed to delete group');
}
})
.catch(error => {
console.error('Error deleting group:', error);
alert('Failed to delete group. Please try again.');
});
};
/* ============================================
Tag Modal
============================================ */
let currentTagId = null;
let selectedTagColor = '#3b82f6';
document.addEventListener('DOMContentLoaded', function() {
initTagModal();
});
function initTagModal() {
// Setup backdrop click to close
const backdrop = document.getElementById('tag-modal-backdrop');
if (backdrop) {
backdrop.addEventListener('click', function(e) {
if (e.target === backdrop) {
closeTagModal();
}
});
}
// Setup color preset buttons
document.querySelectorAll('.color-preset').forEach(btn => {
btn.addEventListener('click', function() {
selectTagColor(this.dataset.color);
});
});
// Setup custom color picker
const colorPicker = document.getElementById('tag-color');
if (colorPicker) {
colorPicker.addEventListener('input', function() {
selectTagColor(this.value, true);
});
}
// Setup form submission
const form = document.getElementById('tag-form');
if (form) {
form.addEventListener('submit', handleTagSubmit);
}
}
function handleTagSubmit(e) {
e.preventDefault();
const nameField = document.getElementById('tag-name');
const name = nameField.value.trim();
if (!name) {
nameField.focus();
return;
}
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
const formData = new FormData();
formData.append('name', name);
formData.append('color', selectedTagColor);
formData.append('csrfmiddlewaretoken', csrfToken);
let url;
if (currentTagId) {
// Update existing tag
url = `/tags/${currentTagId}/`;
formData.append('next', window.location.pathname + window.location.search);
} else {
// Create new tag
url = '/tags/';
formData.append('next', window.location.pathname + window.location.search);
}
fetch(url, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => {
if (response.redirected) {
window.location.href = response.url;
} else if (response.ok) {
window.location.reload();
} else {
throw new Error('Failed to save tag');
}
})
.catch(error => {
console.error('Error saving tag:', error);
alert('Failed to save tag. Please try again.');
});
}
// Make openTagModal globally available for onclick handlers
window.openTagModal = function(tagId = null, name = '', color = '#3b82f6') {
console.log('openTagModal called:', { tagId, name, color });
const backdrop = document.getElementById('tag-modal-backdrop');
const title = document.getElementById('tag-modal-title');
const nameField = document.getElementById('tag-name');
const deleteBtn = document.getElementById('tag-delete-btn');
const submitBtn = document.getElementById('tag-submit-btn');
const tagIdField = document.getElementById('tag-id');
if (!backdrop) {
console.error('Tag modal backdrop not found!');
return;
}
currentTagId = tagId;
if (tagId) {
// Edit mode
if (title) title.textContent = 'Edit Tag';
if (nameField) nameField.value = name;
if (deleteBtn) deleteBtn.style.display = 'inline-flex';
if (submitBtn) submitBtn.textContent = 'Save';
if (tagIdField) tagIdField.value = tagId;
} else {
// Create mode
if (title) title.textContent = 'New Tag';
if (nameField) nameField.value = '';
if (deleteBtn) deleteBtn.style.display = 'none';
if (submitBtn) submitBtn.textContent = 'Create';
if (tagIdField) tagIdField.value = '';
color = '#3b82f6';
}
selectTagColor(color);
backdrop.classList.add('open');
if (nameField) nameField.focus();
};
// Make closeTagModal globally available for onclick handlers
window.closeTagModal = function() {
const backdrop = document.getElementById('tag-modal-backdrop');
if (backdrop) {
backdrop.classList.remove('open');
}
currentTagId = null;
};
function selectTagColor(color) {
selectedTagColor = color;
// Update preset button selection
document.querySelectorAll('.color-preset').forEach(btn => {
btn.classList.toggle('selected', btn.dataset.color.toLowerCase() === color.toLowerCase());
});
// Update custom color picker
const colorPicker = document.getElementById('tag-color');
if (colorPicker) {
colorPicker.value = color;
}
// Update hidden field
const hiddenField = document.getElementById('tag-color-hidden');
if (hiddenField) {
hiddenField.value = color;
}
}
// Make deleteTag globally available for onclick handlers
window.deleteTag = function() {
if (!currentTagId) return;
if (!confirm('Are you sure you want to delete this tag?\n\nTasks with this tag will not be deleted.')) {
return;
}
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
const formData = new FormData();
formData.append('csrfmiddlewaretoken', csrfToken);
formData.append('next', window.location.pathname + window.location.search);
fetch(`/tags/${currentTagId}/delete/`, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => {
if (response.redirected) {
window.location.href = response.url;
} else if (response.ok) {
window.location.reload();
} else {
throw new Error('Failed to delete tag');
}
})
.catch(error => {
console.error('Error deleting tag:', error);
alert('Failed to delete tag. Please try again.');
});
};
+99
View File
@@ -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();
}
}
View File
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class SyncConfig(AppConfig):
name = 'sync'
+45
View File
@@ -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',
},
),
]
+32
View File
@@ -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')},
),
]
View File
+65
View File
@@ -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}"
+27
View File
@@ -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']
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+13
View File
@@ -0,0 +1,13 @@
"""
Sync URLs for KeepItGoing.
"""
from django.urls import path
from . import views
# API URLs (to be included under /api/sync/)
api_urlpatterns = [
path('', views.sync, name='api-sync'),
path('conflicts/', views.get_conflicts, name='api-sync-conflicts'),
path('conflicts/<uuid:conflict_id>/resolve/', views.resolve_conflict, name='api-sync-resolve'),
]
+464
View File
@@ -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
View File
+50
View File
@@ -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']
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class TasksConfig(AppConfig):
name = 'tasks'
+104
View File
@@ -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'],
},
),
]
+86
View File
@@ -0,0 +1,86 @@
# Generated by Django 5.2.9 on 2025-12-10 00:05
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('tasks', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='tag',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tags', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='task',
name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subtasks', to='tasks.task'),
),
migrations.AddField(
model_name='task',
name='tags',
field=models.ManyToManyField(blank=True, related_name='tasks', to='tasks.tag'),
),
migrations.AddField(
model_name='task',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='tag',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tags', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='task',
name='group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tasks', to='tasks.tag'),
),
migrations.AddField(
model_name='taskshare',
name='group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='shares', to='tasks.tag'),
),
migrations.AddField(
model_name='taskshare',
name='owner',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shares_given', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='taskshare',
name='shared_with',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shares_received', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='taskshare',
name='task',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='shares', to='tasks.task'),
),
migrations.AddField(
model_name='timeentry',
name='task',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='time_entries', to='tasks.task'),
),
migrations.AddField(
model_name='timeentry',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='time_entries', to=settings.AUTH_USER_MODEL),
),
migrations.AlterUniqueTogether(
name='tag',
unique_together={('user', 'name')},
),
migrations.AddConstraint(
model_name='taskshare',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('group__isnull', True), ('task__isnull', False)), models.Q(('group__isnull', False), ('task__isnull', True)), _connector='OR'), name='share_task_or_group_not_both'),
),
]
@@ -0,0 +1,135 @@
# Migration to merge TaskGroup into Tag and support multiple tags per task
from django.db import migrations, models
import django.db.models.deletion
def migrate_groups_to_tags(apps, schema_editor):
"""Migrate TaskGroup data to Tag and convert task.group_id to task_tags."""
TaskGroup = apps.get_model('tasks', 'TaskGroup')
Tag = apps.get_model('tasks', 'Tag')
Task = apps.get_model('tasks', 'Task')
# First, migrate all TaskGroups to Tags
group_to_tag_map = {}
for group in TaskGroup.objects.all():
# Check if a tag with the same sync_id already exists
existing_tag = Tag.objects.filter(sync_id=group.sync_id).first()
if existing_tag:
# Update existing tag with group data
existing_tag.description = group.description
existing_tag.icon = group.icon
existing_tag.sort_order = group.sort_order
existing_tag.is_archived = group.is_archived
existing_tag.save()
group_to_tag_map[group.id] = existing_tag
else:
# Create new tag from group
tag = Tag.objects.create(
user=group.user,
name=group.name,
description=group.description,
color=group.color,
icon=group.icon,
sort_order=group.sort_order,
is_archived=group.is_archived,
sync_id=group.sync_id,
)
group_to_tag_map[group.id] = tag
# Convert task.group_id to task_tags entries
for task in Task.objects.exclude(group_id__isnull=True):
if task.group_id in group_to_tag_map:
tag = group_to_tag_map[task.group_id]
task.tags.add(tag)
def reverse_migration(apps, schema_editor):
"""Reverse migration - not fully reversible, just a placeholder."""
pass
class Migration(migrations.Migration):
dependencies = [
('tasks', '0002_initial'),
]
operations = [
# Step 1: Add new fields to Tag model
migrations.AddField(
model_name='tag',
name='description',
field=models.TextField(blank=True, default=''),
preserve_default=False,
),
migrations.AddField(
model_name='tag',
name='icon',
field=models.CharField(blank=True, default='', max_length=50),
preserve_default=False,
),
migrations.AddField(
model_name='tag',
name='sort_order',
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name='tag',
name='is_archived',
field=models.BooleanField(default=False),
),
# Step 2: Remove unique_together constraint on Tag (user, name)
# to allow migration of groups with same names
migrations.AlterUniqueTogether(
name='tag',
unique_together=set(),
),
# Step 3: Change Tag ordering
migrations.AlterModelOptions(
name='tag',
options={'ordering': ['sort_order', 'name']},
),
# Step 4: Run data migration
migrations.RunPython(migrate_groups_to_tags, reverse_migration),
# Step 5: Remove group field from Task
migrations.RemoveField(
model_name='task',
name='group',
),
# Step 6: Update TaskShare - remove old constraint first
migrations.RemoveConstraint(
model_name='taskshare',
name='share_task_or_group_not_both',
),
# Step 7: Rename group field to tag in TaskShare
migrations.RenameField(
model_name='taskshare',
old_name='group',
new_name='tag',
),
# Step 8: Add new constraint for TaskShare
migrations.AddConstraint(
model_name='taskshare',
constraint=models.CheckConstraint(
check=models.Q(
models.Q(('tag__isnull', True), ('task__isnull', False)),
models.Q(('tag__isnull', False), ('task__isnull', True)),
_connector='OR'
),
name='share_task_or_tag_not_both'
),
),
# Step 9: Delete TaskGroup model
migrations.DeleteModel(
name='TaskGroup',
),
]
@@ -0,0 +1,29 @@
# Generated by Django 5.2.9 on 2025-12-13 00:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tasks', '0003_merge_groups_to_tags'),
]
operations = [
migrations.AlterField(
model_name='tag',
name='color',
field=models.CharField(default='#3B82F6', max_length=7),
),
migrations.AlterField(
model_name='tag',
name='name',
field=models.CharField(max_length=255),
),
migrations.AlterField(
model_name='taskshare',
name='tag',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='shares', to='tasks.tag'),
),
]
@@ -0,0 +1,46 @@
# Generated by Django 5.2.9 on 2025-12-13 06:45
from django.db import migrations
def fix_tasks_tags_table(apps, schema_editor):
"""
Fix the tasks_tags junction table to reference the correct 'tags' table
instead of the old 'task_groups' table.
"""
db_alias = schema_editor.connection.alias
# Drop and recreate the tasks_tags table with correct foreign keys
schema_editor.execute(
"DROP TABLE IF EXISTS tasks_tags;"
)
schema_editor.execute(
"""
CREATE TABLE tasks_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id CHAR(32) NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
tag_id CHAR(32) NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
UNIQUE (task_id, tag_id)
);
"""
)
# Create indexes for performance
schema_editor.execute(
"CREATE INDEX tasks_tags_task_id_idx ON tasks_tags(task_id);"
)
schema_editor.execute(
"CREATE INDEX tasks_tags_tag_id_idx ON tasks_tags(tag_id);"
)
class Migration(migrations.Migration):
dependencies = [
('tasks', '0004_alter_tag_color_alter_tag_name_alter_taskshare_tag'),
]
operations = [
migrations.RunPython(fix_tasks_tags_table, migrations.RunPython.noop),
]
@@ -0,0 +1,61 @@
# Generated by Django 5.2.9 on 2025-12-13 07:00
from django.db import migrations
def fix_task_shares_table(apps, schema_editor):
"""
Fix the task_shares table to reference the correct 'tags' table
instead of the old 'task_groups' table.
"""
db_alias = schema_editor.connection.alias
# Drop and recreate the task_shares table with correct foreign keys
schema_editor.execute(
"DROP TABLE IF EXISTS task_shares;"
)
schema_editor.execute(
"""
CREATE TABLE task_shares (
id CHAR(32) NOT NULL PRIMARY KEY,
permission VARCHAR(20) NOT NULL,
sync_id CHAR(32) NOT NULL UNIQUE,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
owner_id CHAR(32) NOT NULL REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED,
shared_with_id CHAR(32) NOT NULL REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED,
task_id CHAR(32) NULL REFERENCES tasks(id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
tag_id CHAR(32) NULL REFERENCES tags(id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
CONSTRAINT share_task_or_tag_not_both CHECK (
(tag_id IS NULL AND task_id IS NOT NULL) OR
(tag_id IS NOT NULL AND task_id IS NULL)
)
);
"""
)
# Create indexes for foreign keys
schema_editor.execute(
"CREATE INDEX task_shares_owner_id_idx ON task_shares(owner_id);"
)
schema_editor.execute(
"CREATE INDEX task_shares_shared_with_id_idx ON task_shares(shared_with_id);"
)
schema_editor.execute(
"CREATE INDEX task_shares_task_id_idx ON task_shares(task_id);"
)
schema_editor.execute(
"CREATE INDEX task_shares_tag_id_idx ON task_shares(tag_id);"
)
class Migration(migrations.Migration):
dependencies = [
('tasks', '0005_fix_tasks_tags_table'),
]
operations = [
migrations.RunPython(fix_task_shares_table, migrations.RunPython.noop),
]
@@ -0,0 +1,28 @@
# Generated by Django 5.2.9 on 2025-12-14 03:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tasks', '0006_fix_task_shares_table'),
]
operations = [
migrations.AddField(
model_name='tag',
name='is_deleted',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='task',
name='is_deleted',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='timeentry',
name='is_deleted',
field=models.BooleanField(default=False),
),
]
View File
+283
View File
@@ -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}"
+203
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+39
View File
@@ -0,0 +1,39 @@
"""
Task URLs for KeepItGoing.
"""
from django.urls import path
from . import views
# API URLs (to be included under /api/tasks/)
api_urlpatterns = [
path('', views.TaskListCreateAPIView.as_view(), name='api-task-list'),
path('<uuid:pk>/', views.TaskDetailAPIView.as_view(), name='api-task-detail'),
path('tags/', views.TagListCreateAPIView.as_view(), name='api-tag-list'),
path('tags/<uuid:pk>/', views.TagDetailAPIView.as_view(), name='api-tag-detail'),
path('time-entries/', views.TimeEntryListCreateAPIView.as_view(), name='api-time-entry-list'),
path('time-entries/<uuid:pk>/', views.TimeEntryDetailAPIView.as_view(), name='api-time-entry-detail'),
path('<uuid:task_id>/start-timer/', views.start_timer, name='api-start-timer'),
path('time-entries/<uuid:entry_id>/stop/', views.stop_timer, name='api-stop-timer'),
path('shares/', views.TaskShareListCreateAPIView.as_view(), name='api-share-list'),
path('shares/<uuid:pk>/', views.TaskShareDeleteAPIView.as_view(), name='api-share-delete'),
]
# Web URLs (to be included at root level)
web_urlpatterns = [
path('', views.DashboardView.as_view(), name='dashboard'),
path('tasks/', views.TaskListView.as_view(), name='task-list'),
path('tasks/new/', views.TaskCreateView.as_view(), name='task-create'),
path('tasks/<uuid:task_id>/', views.TaskDetailView.as_view(), name='task-detail'),
path('tasks/<uuid:task_id>/detail-partial/', views.task_detail_partial, name='task-detail-partial'),
path('tasks/quick-add/', views.task_quick_add, name='task-quick-add'),
path('tasks/<uuid:task_id>/toggle/', views.task_toggle_status, name='task-toggle'),
path('tasks/<uuid:task_id>/delete/', views.task_delete, name='task-delete'),
path('tasks/<uuid:task_id>/subtask/', views.subtask_create, name='subtask-create'),
path('tasks/<uuid:task_id>/timer/start/', views.web_timer_start, name='timer-start'),
path('tasks/<uuid:task_id>/timer/stop/', views.web_timer_stop, name='timer-stop'),
path('tags/', views.TagListView.as_view(), name='tag-list'),
path('tags/new/', views.TagListView.as_view(), name='tag-create'),
path('tags/<uuid:tag_id>/', views.TagDetailView.as_view(), name='tag-detail'),
path('tags/<uuid:tag_id>/delete/', views.tag_delete, name='tag-delete'),
]
+664
View File
@@ -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)
+140
View File
@@ -0,0 +1,140 @@
{% load static %}
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}KeepItGoing{% endblock %}</title>
<link rel="stylesheet" href="{% static 'css/app.css' %}">
{% block extra_css %}{% endblock %}
</head>
<body>
{% block body %}
{% if user.is_authenticated %}
<div class="app-layout {% if not selected_task %}detail-closed{% endif %}" id="app">
<!-- Header -->
<header class="app-header">
<div style="display: flex; align-items: center; gap: var(--space-md);">
<button class="sidebar-toggle" onclick="toggleSidebar()" aria-label="Toggle sidebar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12h18M3 6h18M3 18h18"/>
</svg>
</button>
<a href="{% url 'dashboard' %}" class="app-brand">KeepItGoing</a>
</div>
<div class="app-header-actions">
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle theme" title="Toggle dark/light mode">
<svg id="theme-icon-light" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
<svg id="theme-icon-dark" style="display: none;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
<div class="header-user">
<span>{{ user.email }}</span>
<a href="{% url 'logout' %}" class="btn btn-secondary btn-sm">Logout</a>
</div>
</div>
</header>
<!-- Sidebar -->
<aside class="app-sidebar" id="sidebar">
{% include 'tasks/_sidebar.html' %}
</aside>
<!-- Main Task Pane -->
<main class="task-pane">
{% if messages %}
<div class="messages">
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
<!-- Detail Panel -->
<aside class="detail-pane {% if not selected_task %}hidden{% endif %}" id="detail-pane">
{% block detail %}
{% if selected_task %}
{% include 'tasks/_task_detail.html' with task=selected_task %}
{% else %}
<div class="empty-state">
<p>Select a task to view details</p>
</div>
{% endif %}
{% endblock %}
</aside>
<!-- Mobile overlay -->
<div class="mobile-overlay" id="mobile-overlay" onclick="closeMobileMenus()"></div>
</div>
<!-- Tag Edit/Create Modal (outside app-layout for proper fixed positioning) -->
<div class="modal-backdrop" id="tag-modal-backdrop">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-header">
<h2 class="modal-title" id="tag-modal-title">New Tag</h2>
<button type="button" class="modal-close" onclick="closeTagModal()">
<svg style="width: 20px; height: 20px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
<form id="tag-form" method="post">
{% csrf_token %}
<input type="hidden" id="tag-id" name="group_id" value="">
<div class="form-group">
<label class="form-label" for="tag-name">Name</label>
<input type="text" class="form-input" id="tag-name" name="name" required placeholder="Tag name">
</div>
<div class="form-group">
<label class="form-label">Color</label>
<div class="color-presets">
<button type="button" class="color-preset" data-color="#3b82f6" style="background-color: #3b82f6"></button>
<button type="button" class="color-preset" data-color="#ef4444" style="background-color: #ef4444"></button>
<button type="button" class="color-preset" data-color="#10b981" style="background-color: #10b981"></button>
<button type="button" class="color-preset" data-color="#f59e0b" style="background-color: #f59e0b"></button>
<button type="button" class="color-preset" data-color="#8b5cf6" style="background-color: #8b5cf6"></button>
<button type="button" class="color-preset" data-color="#ec4899" style="background-color: #ec4899"></button>
<button type="button" class="color-preset" data-color="#06b6d4" style="background-color: #06b6d4"></button>
<button type="button" class="color-preset" data-color="#84cc16" style="background-color: #84cc16"></button>
</div>
<div class="color-custom-row">
<label class="color-custom-label">Custom:</label>
<input type="color" id="tag-color" name="color" value="#3b82f6" class="color-custom-input">
</div>
<input type="hidden" id="tag-color-hidden" name="color" value="#3b82f6">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeTagModal()">Cancel</button>
<button type="button" class="btn btn-danger" id="tag-delete-btn" onclick="deleteTag()" style="display: none;">Delete</button>
<button type="submit" class="btn btn-primary" id="tag-submit-btn">Create</button>
</div>
</form>
</div>
</div>
{% else %}
<!-- Non-authenticated layout (auth pages) -->
<div class="auth-layout">
{% block auth_content %}{% endblock %}
</div>
<footer class="footer">
<p>&copy; 2025 Firebug IT. All rights reserved.</p>
</footer>
{% endif %}
{% endblock %}
<script src="{% static 'js/app.js' %}?v=3"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
+89
View File
@@ -0,0 +1,89 @@
<!-- Filters Section -->
<div class="sidebar-section">
<div class="sidebar-section-title">Filters</div>
<nav class="sidebar-nav">
<a href="{% url 'dashboard' %}?filter=all{% if current_tag_id %}&tag={{ current_tag_id }}{% endif %}"
class="sidebar-item {% if current_filter == 'all' or not current_filter %}active{% endif %}">
<svg class="sidebar-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12h18M3 6h18M3 18h18"/>
</svg>
All Tasks
{% if task_counts.all %}<span class="sidebar-item-count">{{ task_counts.all }}</span>{% endif %}
</a>
<a href="{% url 'dashboard' %}?filter=today{% if current_tag_id %}&tag={{ current_tag_id }}{% endif %}"
class="sidebar-item {% if current_filter == 'today' %}active{% endif %}">
<svg class="sidebar-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
Today
{% if task_counts.today %}<span class="sidebar-item-count">{{ task_counts.today }}</span>{% endif %}
</a>
<a href="{% url 'dashboard' %}?filter=upcoming{% if current_tag_id %}&tag={{ current_tag_id }}{% endif %}"
class="sidebar-item {% if current_filter == 'upcoming' %}active{% endif %}">
<svg class="sidebar-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<path d="M16 2v4M8 2v4M3 10h18"/>
</svg>
Upcoming
{% if task_counts.upcoming %}<span class="sidebar-item-count">{{ task_counts.upcoming }}</span>{% endif %}
</a>
<a href="{% url 'dashboard' %}?filter=overdue{% if current_tag_id %}&tag={{ current_tag_id }}{% endif %}"
class="sidebar-item {% if current_filter == 'overdue' %}active{% endif %}">
<svg class="sidebar-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M12 8v4M12 16h.01"/>
</svg>
Overdue
{% if task_counts.overdue %}<span class="sidebar-item-count text-danger">{{ task_counts.overdue }}</span>{% endif %}
</a>
<a href="{% url 'dashboard' %}?filter=completed{% if current_tag_id %}&tag={{ current_tag_id }}{% endif %}"
class="sidebar-item {% if current_filter == 'completed' %}active{% endif %}">
<svg class="sidebar-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<path d="M22 4L12 14.01l-3-3"/>
</svg>
Completed
{% if task_counts.completed %}<span class="sidebar-item-count">{{ task_counts.completed }}</span>{% endif %}
</a>
</nav>
</div>
<!-- Tags Section -->
<div class="sidebar-section">
<div class="sidebar-section-title">Tags</div>
<nav class="sidebar-nav">
{% for tag in tags %}
<div class="sidebar-group-row">
<a href="{% url 'dashboard' %}?{% if current_filter %}filter={{ current_filter }}&{% endif %}tag={{ tag.id }}"
class="sidebar-item sidebar-group-item {% if current_tag_id == tag.id|stringformat:'s' %}active{% endif %}">
<span class="group-color-dot" style="background-color: {{ tag.color }}"></span>
{{ tag.name }}
{% if tag.task_count %}<span class="sidebar-item-count">{{ tag.task_count }}</span>{% endif %}
</a>
<button type="button" class="sidebar-group-edit" title="Edit tag"
onclick="openTagModal('{{ tag.id }}', '{{ tag.name|escapejs }}', '{{ tag.color }}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
</div>
{% empty %}
<div class="sidebar-item text-muted" style="font-size: var(--font-sm); cursor: default;">
No tags yet
</div>
{% endfor %}
<a href="{% url 'dashboard' %}?{% if current_filter %}filter={{ current_filter }}{% endif %}"
class="sidebar-item sidebar-group-item {% if not current_tag_id %}active{% endif %}">
<span class="group-color-dot" style="background-color: var(--text-muted)"></span>
All Tags
</a>
</nav>
<button type="button" class="sidebar-add-btn" onclick="openTagModal()">
<svg style="width: 14px; height: 14px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"/>
</svg>
Add Tag
</button>
</div>
+162
View File
@@ -0,0 +1,162 @@
<!-- Task Detail Panel -->
<div class="detail-header">
<h2 style="font-size: var(--font-lg); font-weight: 600;">Task Details</h2>
<button class="detail-close" onclick="closeDetail()" aria-label="Close detail panel">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
<form method="post" action="{% url 'task-detail' task.id %}" id="task-detail-form">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'dashboard' %}?selected={{ task.id }}{% if request.GET.filter %}&filter={{ request.GET.filter }}{% endif %}{% if request.GET.tag %}&tag={{ request.GET.tag }}{% endif %}">
<!-- Title -->
<div class="form-group">
<label class="form-label" for="detail-title">Title</label>
<input type="text" class="form-input" id="detail-title" name="title" value="{{ task.title }}" required>
</div>
<!-- Description -->
<div class="form-group">
<label class="form-label" for="detail-description">Description</label>
<textarea class="form-textarea" id="detail-description" name="description" rows="3">{{ task.description }}</textarea>
</div>
<!-- Status & Priority -->
<div class="form-row">
<div class="form-group">
<label class="form-label" for="detail-status">Status</label>
<select class="form-select" id="detail-status" name="status">
<option value="pending" {% if task.status == 'pending' %}selected{% endif %}>Pending</option>
<option value="in_progress" {% if task.status == 'in_progress' %}selected{% endif %}>In Progress</option>
<option value="completed" {% if task.status == 'completed' %}selected{% endif %}>Completed</option>
<option value="cancelled" {% if task.status == 'cancelled' %}selected{% endif %}>Cancelled</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="detail-priority">Priority</label>
<select class="form-select" id="detail-priority" name="priority">
<option value="low" {% if task.priority == 'low' %}selected{% endif %}>Low</option>
<option value="medium" {% if task.priority == 'medium' %}selected{% endif %}>Medium</option>
<option value="high" {% if task.priority == 'high' %}selected{% endif %}>High</option>
<option value="urgent" {% if task.priority == 'urgent' %}selected{% endif %}>Urgent</option>
</select>
</div>
</div>
<!-- Due Date & Time -->
<div class="form-row">
<div class="form-group">
<label class="form-label" for="detail-due-date">Due Date</label>
<input type="date" class="form-input" id="detail-due-date" name="due_date" value="{{ task.due_date|date:'Y-m-d' }}">
</div>
<div class="form-group">
<label class="form-label" for="detail-due-time">Due Time</label>
<input type="time" class="form-input" id="detail-due-time" name="due_time" value="{{ task.due_time|time:'H:i' }}">
</div>
</div>
<!-- Tags -->
{% if tags %}
<div class="form-group">
<label class="form-label">Tags</label>
<div style="display: flex; flex-wrap: wrap; gap: var(--space-sm);">
{% for tag in tags %}
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="checkbox" name="tags" value="{{ tag.id }}" {% if tag in task.tags.all %}checked{% endif %}>
<span style="color: {{ tag.color }}">{{ tag.name }}</span>
</label>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Recurrence -->
<div class="form-group">
<label class="form-label" for="detail-recurrence">Recurrence</label>
<select class="form-select" id="detail-recurrence" name="recurrence">
<option value="none" {% if task.recurrence == 'none' %}selected{% endif %}>None</option>
<option value="daily" {% if task.recurrence == 'daily' %}selected{% endif %}>Daily</option>
<option value="weekly" {% if task.recurrence == 'weekly' %}selected{% endif %}>Weekly</option>
<option value="biweekly" {% if task.recurrence == 'biweekly' %}selected{% endif %}>Bi-weekly</option>
<option value="monthly" {% if task.recurrence == 'monthly' %}selected{% endif %}>Monthly</option>
<option value="yearly" {% if task.recurrence == 'yearly' %}selected{% endif %}>Yearly</option>
</select>
</div>
<!-- Save Button (moved inside form, before subtasks) -->
<div class="detail-actions" style="margin-bottom: var(--space-lg);">
<button type="submit" class="btn btn-primary" style="flex: 1;">Save Changes</button>
</div>
</form>
<!-- Subtasks Section (OUTSIDE the main form) -->
<div style="background: var(--bg); border-radius: var(--radius-sm); padding: var(--space-md); margin-bottom: var(--space-lg);">
<div style="font-size: var(--font-sm); font-weight: 500; color: var(--text-secondary); margin-bottom: var(--space-sm);">Subtasks</div>
<!-- Add subtask form (now separate) -->
<form method="post" action="{% url 'subtask-create' task.id %}" style="display: flex; gap: var(--space-sm); margin-bottom: var(--space-sm);">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'dashboard' %}?selected={{ task.id }}{% if request.GET.filter %}&filter={{ request.GET.filter }}{% endif %}{% if request.GET.tag %}&tag={{ request.GET.tag }}{% endif %}">
<input type="text" name="title" class="form-input" placeholder="Add a subtask..." style="flex: 1; padding: var(--space-xs) var(--space-sm); font-size: var(--font-sm);" required>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
</form>
<!-- Subtask list -->
{% if task.subtasks.all %}
<div style="display: flex; flex-direction: column; gap: var(--space-xs);">
{% for subtask in task.subtasks.all %}
<div style="display: flex; align-items: center; gap: var(--space-sm); padding: var(--space-xs); border-radius: var(--radius-xs); background: var(--surface);">
<form method="post" action="{% url 'task-toggle' subtask.id %}" style="display: flex;">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'dashboard' %}?selected={{ task.id }}{% if request.GET.filter %}&filter={{ request.GET.filter }}{% endif %}{% if request.GET.tag %}&tag={{ request.GET.tag }}{% endif %}">
<button type="submit" class="task-checkbox {% if subtask.status == 'completed' %}checked{% endif %}" style="width: 16px; height: 16px; font-size: 10px;">
{% if subtask.status == 'completed' %}&#10003;{% endif %}
</button>
</form>
<span style="font-size: var(--font-sm); {% if subtask.status == 'completed' %}text-decoration: line-through; color: var(--text-muted);{% endif %}">
{{ subtask.title }}
</span>
</div>
{% endfor %}
</div>
{% else %}
<div style="font-size: var(--font-sm); color: var(--text-muted);">No subtasks</div>
{% endif %}
</div>
<!-- Time Tracker (OUTSIDE the main form) -->
<div class="time-tracker">
<div class="time-tracker-header">
<span class="time-tracker-label">Time Spent</span>
<span class="time-tracker-total">{{ task.total_time_formatted|default:"0:00:00" }}</span>
</div>
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-sm);">
{% if running_timer and running_timer.task_id == task.id %}
<form method="post" action="{% url 'timer-stop' task.id %}" style="flex: 1;">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'dashboard' %}?selected={{ task.id }}{% if request.GET.filter %}&filter={{ request.GET.filter }}{% endif %}{% if request.GET.tag %}&tag={{ request.GET.tag }}{% endif %}">
<button type="submit" class="btn btn-danger btn-full btn-sm">Stop Timer</button>
</form>
{% else %}
<form method="post" action="{% url 'timer-start' task.id %}" style="flex: 1;">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'dashboard' %}?selected={{ task.id }}{% if request.GET.filter %}&filter={{ request.GET.filter }}{% endif %}{% if request.GET.tag %}&tag={{ request.GET.tag }}{% endif %}">
<button type="submit" class="btn btn-secondary btn-full btn-sm">Start Timer</button>
</form>
{% endif %}
</div>
</div>
<!-- Delete Button (separate form) -->
<div class="detail-actions" style="margin-top: var(--space-lg);">
<form method="post" action="{% url 'task-delete' task.id %}" onsubmit="return confirm('Delete this task?')">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'dashboard' %}">
<button type="submit" class="btn btn-danger btn-sm">Delete Task</button>
</form>
</div>
+46
View File
@@ -0,0 +1,46 @@
<div class="task-item priority-{{ task.priority }} {% if task.is_overdue %}overdue{% endif %} {% if task.status == 'completed' %}completed{% endif %} {% if selected_task_id == task.id|stringformat:'s' %}selected{% endif %}"
data-task-id="{{ task.id }}"
onclick="selectTask('{{ task.id }}')">
<!-- Checkbox -->
<form method="post" action="{% url 'task-toggle' task.id %}" class="task-toggle" onclick="event.stopPropagation();">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="task-checkbox {% if task.status == 'completed' %}checked{% endif %}">
{% if task.status == 'completed' %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<path d="M5 13l4 4L19 7"/>
</svg>
{% endif %}
</button>
</form>
<!-- Content -->
<div class="task-content">
<div class="task-title">{{ task.title }}</div>
<div class="task-meta">
{% if task.due_date %}
<span class="task-due {% if task.is_overdue %}overdue{% endif %}">
📅 {{ task.due_date|date:"M j, Y" }}{% if task.due_time %} 🕐 {{ task.due_time|time:"g:i A" }}{% endif %}
</span>
{% endif %}
{% if task.due_date and task.tags.all %}
<span class="task-meta-separator">·</span>
{% endif %}
{% for tag in task.tags.all %}
<span class="task-group-label" style="background-color: {{ tag.color }}20; color: {{ tag.color }}">
{{ tag.name }}
</span>
{% endfor %}
{% if task.total_time_spent > 0 %}
{% if task.due_date or task.tags.all %}
<span class="task-meta-separator">·</span>
{% endif %}
<span class="task-time-spent">⏱️ {{ task.total_time_formatted }}</span>
{% endif %}
</div>
</div>
<!-- Priority Badge -->
<span class="priority-badge {{ task.priority }}">{{ task.priority }}</span>
</div>
+87
View File
@@ -0,0 +1,87 @@
{% extends 'base.html' %}
{% block title %}{{ page_title|default:"All Tasks" }} - KeepItGoing{% endblock %}
{% block content %}
<!-- Header -->
<div class="task-pane-header">
<h1 class="task-pane-title">{{ page_title|default:"All Tasks" }}</h1>
<div style="display: flex; align-items: center; gap: var(--space-md);">
<span class="task-count">{{ tasks|length }} task{{ tasks|length|pluralize }}</span>
<select id="sort-select" onchange="updateSort()" style="padding: 4px 8px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--text);">
<option value="default" {% if current_sort == 'default' %}selected{% endif %}>Default</option>
<option value="due_date" {% if current_sort == 'due_date' %}selected{% endif %}>Due Date (Earliest)</option>
<option value="due_date_desc" {% if current_sort == 'due_date_desc' %}selected{% endif %}>Due Date (Latest)</option>
<option value="priority" {% if current_sort == 'priority' %}selected{% endif %}>Priority (High to Low)</option>
<option value="priority_low" {% if current_sort == 'priority_low' %}selected{% endif %}>Priority (Low to High)</option>
</select>
</div>
</div>
<!-- Quick Add -->
<form method="post" action="{% url 'task-quick-add' %}" class="quick-add">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
{% if current_tag_id %}
<input type="hidden" name="tag" value="{{ current_tag_id }}">
{% endif %}
<input type="text" name="title" class="quick-add-input" placeholder="Add a new task..." required>
<button type="submit" class="btn btn-primary">Add</button>
</form>
<!-- Running Timer -->
{% if running_timer %}
<div class="time-tracker" style="background-color: var(--accent); color: var(--text-on-accent); margin-bottom: var(--space-lg);">
<div class="time-tracker-header">
<span class="time-tracker-label" style="color: var(--text-on-accent); opacity: 0.9;">Timer running for: <strong>{{ running_timer.task.title }}</strong></span>
<span class="time-tracker-total" id="running-timer" data-started="{{ running_timer.started_at.isoformat }}">00:00:00</span>
</div>
</div>
{% endif %}
<!-- Task List -->
{% if tasks %}
<div class="task-list" id="task-list">
{% for task in tasks %}
{% include 'tasks/_task_item.html' %}
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>No tasks yet.</p>
<p class="text-muted">Add one above!</p>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
// Running timer display
const timerDisplay = document.getElementById('running-timer');
if (timerDisplay) {
const startedAt = new Date(timerDisplay.dataset.started);
function updateTimer() {
const now = new Date();
const diff = Math.floor((now - startedAt) / 1000);
const hours = Math.floor(diff / 3600).toString().padStart(2, '0');
const minutes = Math.floor((diff % 3600) / 60).toString().padStart(2, '0');
const seconds = (diff % 60).toString().padStart(2, '0');
timerDisplay.textContent = `${hours}:${minutes}:${seconds}`;
}
updateTimer();
setInterval(updateTimer, 1000);
}
// Sort change handler
function updateSort() {
const sortValue = document.getElementById('sort-select').value;
const url = new URL(window.location.href);
if (sortValue === 'default') {
url.searchParams.delete('sort');
} else {
url.searchParams.set('sort', sortValue);
}
window.location.href = url.toString();
}
</script>
{% endblock %}
+61
View File
@@ -0,0 +1,61 @@
{% extends 'base.html' %}
{% block title %}Edit {{ tag.name }} - KeepItGoing{% endblock %}
{% block content %}
<!-- Header -->
<div class="task-pane-header">
<h1 class="task-pane-title">Edit Tag</h1>
</div>
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-xl); max-width: 500px;">
<form method="post">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'tag-list' %}">
<div class="form-group">
<label class="form-label" for="name">Name</label>
<input type="text" class="form-input" id="name" name="name" value="{{ tag.name }}" required>
</div>
<div class="form-group">
<label class="form-label" for="color">Color</label>
<div style="display: flex; align-items: center; gap: var(--space-md);">
<input type="color" id="color" name="color" value="{{ tag.color|default:'#3B82F6' }}"
style="height: 40px; width: 80px; padding: 2px; border: 1px solid var(--border); border-radius: var(--radius-sm); cursor: pointer;">
<div style="width: 24px; height: 24px; border-radius: 50%; background: {{ tag.color|default:'#3B82F6' }};" id="colorPreview"></div>
</div>
</div>
<div class="form-group">
<label class="form-label" for="description">Description</label>
<textarea class="form-input" id="description" name="description" rows="3" placeholder="Optional description">{{ tag.description|default:'' }}</textarea>
</div>
<div style="display: flex; gap: var(--space-md); margin-top: var(--space-xl);">
<button type="submit" class="btn btn-primary">Save Changes</button>
<a href="{% url 'tag-list' %}" class="btn btn-secondary">Cancel</a>
</div>
</form>
<!-- Delete Section -->
<div style="margin-top: var(--space-xl); padding-top: var(--space-xl); border-top: 1px solid var(--border);">
<h3 style="color: var(--danger); margin-bottom: var(--space-md);">Danger Zone</h3>
<p style="color: var(--text-secondary); font-size: var(--font-sm); margin-bottom: var(--space-md);">
Deleting this tag will not delete the tasks with it. Tasks will simply lose this tag.
</p>
<form method="post" action="{% url 'tag-delete' tag.id %}" onsubmit="return confirm('Are you sure you want to delete this tag?');">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'tag-list' %}">
<button type="submit" class="btn" style="background: var(--danger); color: white;">Delete Tag</button>
</form>
</div>
</div>
<script>
// Update color preview when color picker changes
document.getElementById('color').addEventListener('input', function(e) {
document.getElementById('colorPreview').style.background = e.target.value;
});
</script>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends 'base.html' %}
{% block title %}Tags - KeepItGoing{% endblock %}
{% block content %}
<!-- Header -->
<div class="task-pane-header">
<h1 class="task-pane-title">Tags</h1>
<span class="task-count">{{ tags|length }} tag{{ tags|length|pluralize }}</span>
</div>
<!-- Create Tag -->
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-lg); margin-bottom: var(--space-lg);">
<h2 style="font-size: var(--font-lg); margin-bottom: var(--space-md);">Create New Tag</h2>
<form method="post" style="display: flex; gap: var(--space-md); flex-wrap: wrap; align-items: flex-end;">
{% csrf_token %}
<div class="form-group" style="flex: 1; min-width: 200px; margin-bottom: 0;">
<label class="form-label" for="name">Name</label>
<input type="text" class="form-input" id="name" name="name" required placeholder="Enter tag name">
</div>
<div class="form-group" style="margin-bottom: 0;">
<label class="form-label" for="color">Color</label>
<input type="color" id="color" name="color" value="#3B82F6" style="height: 38px; width: 60px; padding: 2px; border: 1px solid var(--border); border-radius: var(--radius-sm); cursor: pointer;">
</div>
<button type="submit" class="btn btn-primary">Create Tag</button>
</form>
</div>
<!-- Tag List -->
{% if tags %}
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: var(--space-lg);">
{% for tag in tags %}
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-lg); border-left: 4px solid {{ tag.color }};">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-sm);">
<h3 style="font-size: var(--font-lg); color: {{ tag.color }}; margin: 0;">{{ tag.name }}</h3>
{% if tag.is_archived %}
<span style="font-size: var(--font-xs); background: var(--text-muted); color: var(--bg); padding: 2px 8px; border-radius: 10px;">Archived</span>
{% endif %}
</div>
<p style="color: var(--text-secondary); font-size: var(--font-sm); margin-bottom: var(--space-md);">
{{ tag.description|default:"No description" }}
</p>
<div style="display: flex; gap: var(--space-lg); font-size: var(--font-sm); color: var(--text-muted); margin-bottom: var(--space-md);">
<span>{{ tag.tasks.count }} tasks</span>
</div>
<div style="display: flex; gap: var(--space-sm);">
<a href="{% url 'dashboard' %}?tag={{ tag.id }}" class="btn btn-secondary btn-sm">View Tasks</a>
<a href="{% url 'tag-detail' tag.id %}" class="btn btn-secondary btn-sm" style="cursor: pointer; pointer-events: auto;">Edit</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>No tags created yet.</p>
<p class="text-muted">Create your first tag above!</p>
</div>
{% endif %}
{% endblock %}
+87
View File
@@ -0,0 +1,87 @@
{% extends 'base.html' %}
{% block title %}New Task - KeepItGoing{% endblock %}
{% block content %}
<!-- Header -->
<div class="task-pane-header">
<h1 class="task-pane-title">Create New Task</h1>
<a href="{% url 'dashboard' %}" class="btn btn-secondary">Cancel</a>
</div>
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-xl); max-width: 600px;">
<form method="post">
{% csrf_token %}
<div class="form-group">
<label class="form-label" for="title">Title *</label>
<input type="text" class="form-input" id="title" name="title" required autofocus placeholder="Enter task title">
</div>
<div class="form-group">
<label class="form-label" for="description">Description</label>
<textarea class="form-textarea" id="description" name="description" rows="3" placeholder="Add a description..."></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="status">Status</label>
<select class="form-select" id="status" name="status">
<option value="pending" selected>Pending</option>
<option value="in_progress">In Progress</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="priority">Priority</label>
<select class="form-select" id="priority" name="priority">
<option value="low">Low</option>
<option value="medium" selected>Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="due_date">Due Date</label>
<input type="date" class="form-input" id="due_date" name="due_date">
</div>
<div class="form-group">
<label class="form-label" for="due_time">Due Time</label>
<input type="time" class="form-input" id="due_time" name="due_time">
</div>
</div>
<div class="form-group">
<label class="form-label" for="recurrence">Recurrence</label>
<select class="form-select" id="recurrence" name="recurrence">
<option value="none" selected>None</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="biweekly">Bi-weekly</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
</div>
{% if tags %}
<div class="form-group">
<label class="form-label">Tags</label>
<div style="display: flex; flex-wrap: wrap; gap: var(--space-sm);">
{% for tag in tags %}
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="checkbox" name="tags" value="{{ tag.id }}">
<span style="color: {{ tag.color }}">{{ tag.name }}</span>
</label>
{% endfor %}
</div>
</div>
{% endif %}
<button type="submit" class="btn btn-primary">Create Task</button>
</form>
</div>
{% endblock %}
+151
View File
@@ -0,0 +1,151 @@
{% extends 'base.html' %}
{% block title %}{{ task.title }} - KeepItGoing{% endblock %}
{% block content %}
<!-- Header -->
<div class="task-pane-header">
<h1 class="task-pane-title">Edit Task</h1>
<div style="display: flex; gap: var(--space-sm);">
<a href="{% url 'dashboard' %}" class="btn btn-secondary">Back</a>
<form method="post" action="{% url 'task-delete' task.id %}" style="display: inline;" onsubmit="return confirm('Delete this task?')">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'dashboard' %}">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 350px; gap: var(--space-lg);">
<!-- Main Form -->
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-xl);">
<form method="post">
{% csrf_token %}
<div class="form-group">
<label class="form-label" for="title">Title</label>
<input type="text" class="form-input" id="title" name="title" value="{{ task.title }}" required>
</div>
<div class="form-group">
<label class="form-label" for="description">Description</label>
<textarea class="form-textarea" id="description" name="description" rows="4">{{ task.description }}</textarea>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="status">Status</label>
<select class="form-select" id="status" name="status">
<option value="pending" {% if task.status == 'pending' %}selected{% endif %}>Pending</option>
<option value="in_progress" {% if task.status == 'in_progress' %}selected{% endif %}>In Progress</option>
<option value="completed" {% if task.status == 'completed' %}selected{% endif %}>Completed</option>
<option value="cancelled" {% if task.status == 'cancelled' %}selected{% endif %}>Cancelled</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="priority">Priority</label>
<select class="form-select" id="priority" name="priority">
<option value="low" {% if task.priority == 'low' %}selected{% endif %}>Low</option>
<option value="medium" {% if task.priority == 'medium' %}selected{% endif %}>Medium</option>
<option value="high" {% if task.priority == 'high' %}selected{% endif %}>High</option>
<option value="urgent" {% if task.priority == 'urgent' %}selected{% endif %}>Urgent</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="due_date">Due Date</label>
<input type="date" class="form-input" id="due_date" name="due_date" value="{{ task.due_date|date:'Y-m-d' }}">
</div>
<div class="form-group">
<label class="form-label" for="due_time">Due Time</label>
<input type="time" class="form-input" id="due_time" name="due_time" value="{{ task.due_time|time:'H:i' }}">
</div>
</div>
<!-- Tags -->
{% if tags %}
<div class="form-group">
<label class="form-label">Tags</label>
<div style="display: flex; flex-wrap: wrap; gap: var(--space-sm);">
{% for tag in tags %}
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="checkbox" name="tags" value="{{ tag.id }}" {% if tag in task.tags.all %}checked{% endif %}>
<span style="color: {{ tag.color }}">{{ tag.name }}</span>
</label>
{% endfor %}
</div>
</div>
{% endif %}
<div class="form-group">
<label class="form-label" for="recurrence">Recurrence</label>
<select class="form-select" id="recurrence" name="recurrence">
<option value="none" {% if task.recurrence == 'none' %}selected{% endif %}>None</option>
<option value="daily" {% if task.recurrence == 'daily' %}selected{% endif %}>Daily</option>
<option value="weekly" {% if task.recurrence == 'weekly' %}selected{% endif %}>Weekly</option>
<option value="biweekly" {% if task.recurrence == 'biweekly' %}selected{% endif %}>Bi-weekly</option>
<option value="monthly" {% if task.recurrence == 'monthly' %}selected{% endif %}>Monthly</option>
<option value="yearly" {% if task.recurrence == 'yearly' %}selected{% endif %}>Yearly</option>
</select>
</div>
{% if tags %}
<div class="form-group">
<label class="form-label">Tags</label>
<div style="display: flex; flex-wrap: wrap; gap: var(--space-sm);">
{% for tag in tags %}
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="checkbox" name="tags" value="{{ tag.id }}" {% if tag in task.tags.all %}checked{% endif %}>
<span style="color: {{ tag.color }}">{{ tag.name }}</span>
</label>
{% endfor %}
</div>
</div>
{% endif %}
<button type="submit" class="btn btn-primary">Save Changes</button>
</form>
</div>
<!-- Sidebar: Time Tracking & Subtasks -->
<div>
<!-- Time Tracking -->
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-lg); margin-bottom: var(--space-lg);">
<h2 style="font-size: var(--font-lg); margin-bottom: var(--space-md);">Time Tracking</h2>
<div style="font-size: var(--font-xxl); font-weight: 600; font-family: monospace; margin-bottom: var(--space-md);">
{{ task.total_time_formatted }}
</div>
{% if time_entries %}
<div style="max-height: 200px; overflow-y: auto;">
{% for entry in time_entries %}
<div style="display: flex; justify-content: space-between; padding: var(--space-sm); background: var(--bg); border-radius: var(--radius-sm); margin-bottom: var(--space-xs); font-size: var(--font-sm);">
<span>{{ entry.started_at|date:"M j, H:i" }}</span>
<span>{{ entry.duration_seconds|default:"Running" }}s</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted" style="font-size: var(--font-sm);">No time entries yet</p>
{% endif %}
</div>
<!-- Subtasks -->
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-lg);">
<h2 style="font-size: var(--font-lg); margin-bottom: var(--space-md);">Subtasks</h2>
{% if task.subtasks.count > 0 %}
<div class="task-list">
{% for subtask in task.subtasks.all %}
{% include 'tasks/_task_item.html' with task=subtask %}
{% endfor %}
</div>
{% else %}
<p class="text-muted" style="font-size: var(--font-sm);">No subtasks</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+58
View File
@@ -0,0 +1,58 @@
{% extends 'base.html' %}
{% block title %}Tasks - KeepItGoing{% endblock %}
{% block content %}
<!-- Header -->
<div class="task-pane-header">
<h1 class="task-pane-title">All Tasks</h1>
<span class="task-count">{{ tasks|length }} task{{ tasks|length|pluralize }}</span>
</div>
<!-- Filters -->
<div style="display: flex; gap: var(--space-sm); flex-wrap: wrap; margin-bottom: var(--space-lg);">
<form method="get" style="display: flex; gap: var(--space-sm); flex-wrap: wrap; flex: 1;">
<select name="status" class="form-select" onchange="this.form.submit()" style="width: auto;">
<option value="">All Status</option>
<option value="pending" {% if current_status == 'pending' %}selected{% endif %}>Pending</option>
<option value="in_progress" {% if current_status == 'in_progress' %}selected{% endif %}>In Progress</option>
<option value="completed" {% if current_status == 'completed' %}selected{% endif %}>Completed</option>
<option value="cancelled" {% if current_status == 'cancelled' %}selected{% endif %}>Cancelled</option>
</select>
<select name="priority" class="form-select" onchange="this.form.submit()" style="width: auto;">
<option value="">All Priority</option>
<option value="urgent" {% if current_priority == 'urgent' %}selected{% endif %}>Urgent</option>
<option value="high" {% if current_priority == 'high' %}selected{% endif %}>High</option>
<option value="medium" {% if current_priority == 'medium' %}selected{% endif %}>Medium</option>
<option value="low" {% if current_priority == 'low' %}selected{% endif %}>Low</option>
</select>
<select name="tag" class="form-select" onchange="this.form.submit()" style="width: auto;">
<option value="">All Tags</option>
{% for tag in tags %}
<option value="{{ tag.id }}" {% if current_tag == tag.id|stringformat:'s' %}selected{% endif %}>
{{ tag.name }}
</option>
{% endfor %}
</select>
<a href="{% url 'task-list' %}" class="btn btn-secondary btn-sm">Clear Filters</a>
</form>
<a href="{% url 'task-create' %}" class="btn btn-primary">New Task</a>
</div>
<!-- Task List -->
{% if tasks %}
<div class="task-list">
{% for task in tasks %}
{% include 'tasks/_task_item.html' %}
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<p>No tasks found</p>
<p class="text-muted">Try adjusting your filters or create a new task.</p>
</div>
{% endif %}
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
{% extends 'base.html' %}
{% block title %}Login - KeepItGoing{% endblock %}
{% block auth_content %}
<div class="auth-card">
<h1 class="auth-title">Login</h1>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form method="post">
{% csrf_token %}
<div class="form-group">
<label class="form-label" for="email">Email</label>
<input type="email" class="form-input" id="email" name="email" required autofocus>
</div>
<div class="form-group">
<label class="form-label" for="password">Password</label>
<input type="password" class="form-input" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary btn-full">Login</button>
</form>
<p class="auth-footer">
Don't have an account? <a href="{% url 'register' %}">Register</a>
</p>
</div>
{% endblock %}
+77
View File
@@ -0,0 +1,77 @@
{% extends 'base.html' %}
{% block title %}Profile - KeepItGoing{% endblock %}
{% block content %}
<div class="page-header">
<h1>Profile Settings</h1>
</div>
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
<div class="card">
<form method="post">
{% csrf_token %}
<div class="form-row">
<div class="form-group">
<label for="first_name">First Name</label>
<input type="text" id="first_name" name="first_name" value="{{ user.first_name }}">
</div>
<div class="form-group">
<label for="last_name">Last Name</label>
<input type="text" id="last_name" name="last_name" value="{{ user.last_name }}">
</div>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" value="{{ user.email }}" disabled>
<small>Email cannot be changed</small>
</div>
<div class="form-group">
<label for="timezone">Timezone</label>
<select id="timezone" name="timezone">
<option value="UTC" {% if user.timezone == 'UTC' %}selected{% endif %}>UTC</option>
<option value="America/New_York" {% if user.timezone == 'America/New_York' %}selected{% endif %}>Eastern Time</option>
<option value="America/Chicago" {% if user.timezone == 'America/Chicago' %}selected{% endif %}>Central Time</option>
<option value="America/Denver" {% if user.timezone == 'America/Denver' %}selected{% endif %}>Mountain Time</option>
<option value="America/Los_Angeles" {% if user.timezone == 'America/Los_Angeles' %}selected{% endif %}>Pacific Time</option>
<option value="Europe/London" {% if user.timezone == 'Europe/London' %}selected{% endif %}>London</option>
<option value="Europe/Paris" {% if user.timezone == 'Europe/Paris' %}selected{% endif %}>Paris</option>
<option value="Asia/Tokyo" {% if user.timezone == 'Asia/Tokyo' %}selected{% endif %}>Tokyo</option>
</select>
</div>
<div class="form-group">
<label for="default_reminder_minutes">Default Reminder (minutes before due)</label>
<select id="default_reminder_minutes" name="default_reminder_minutes">
<option value="0" {% if user.default_reminder_minutes == 0 %}selected{% endif %}>No reminder</option>
<option value="5" {% if user.default_reminder_minutes == 5 %}selected{% endif %}>5 minutes</option>
<option value="15" {% if user.default_reminder_minutes == 15 %}selected{% endif %}>15 minutes</option>
<option value="30" {% if user.default_reminder_minutes == 30 %}selected{% endif %}>30 minutes</option>
<option value="60" {% if user.default_reminder_minutes == 60 %}selected{% endif %}>1 hour</option>
<option value="1440" {% if user.default_reminder_minutes == 1440 %}selected{% endif %}>1 day</option>
</select>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="email_notifications" {% if user.email_notifications %}checked{% endif %}>
Email Notifications
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="push_notifications" {% if user.push_notifications %}checked{% endif %}>
Push Notifications
</label>
</div>
<button type="submit" class="btn btn-primary">Save Changes</button>
</form>
</div>
{% endblock %}
+42
View File
@@ -0,0 +1,42 @@
{% extends 'base.html' %}
{% block title %}Register - KeepItGoing{% endblock %}
{% block auth_content %}
<div class="auth-card">
<h1 class="auth-title">Create Account</h1>
{% if errors %}
<div class="alert alert-error">
{% for field, error in errors.items %}
<p>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
<form method="post">
{% csrf_token %}
<div class="form-group">
<label class="form-label" for="email">Email</label>
<input type="email" class="form-input" id="email" name="email" required autofocus>
</div>
<div class="form-group">
<label class="form-label" for="username">Username</label>
<input type="text" class="form-input" id="username" name="username" required>
</div>
<div class="form-group">
<label class="form-label" for="password">Password</label>
<input type="password" class="form-input" id="password" name="password" required>
</div>
<div class="form-group">
<label class="form-label" for="password_confirm">Confirm Password</label>
<input type="password" class="form-input" id="password_confirm" name="password_confirm" required>
</div>
<button type="submit" class="btn btn-primary btn-full">Register</button>
</form>
<p class="auth-footer">
Already have an account? <a href="{% url 'login' %}">Login</a>
</p>
</div>
{% endblock %}
View File
+39
View File
@@ -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']
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
name = 'users'
+71
View File
@@ -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')},
},
),
]
View File
+69
View File
@@ -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}"
+95
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+30
View File
@@ -0,0 +1,30 @@
"""
User URLs for KeepItGoing.
"""
from django.urls import path
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
from . import views
# API URLs (to be included under /api/users/)
api_urlpatterns = [
path('register/', views.RegisterAPIView.as_view(), name='api-register'),
path('profile/', views.UserProfileAPIView.as_view(), name='api-profile'),
path('change-password/', views.ChangePasswordAPIView.as_view(), name='api-change-password'),
path('devices/', views.DeviceTokenAPIView.as_view(), name='api-devices'),
path('devices/<uuid:pk>/', views.DeviceTokenDeleteAPIView.as_view(), name='api-device-delete'),
path('token/', TokenObtainPairView.as_view(), name='token-obtain-pair'),
path('token/refresh/', TokenRefreshView.as_view(), name='token-refresh'),
]
# Web URLs (to be included at root level)
web_urlpatterns = [
path('login/', views.LoginView.as_view(), name='login'),
path('logout/', views.LogoutView.as_view(), name='logout'),
path('register/', views.RegisterView.as_view(), name='register'),
path('profile/', views.ProfileView.as_view(), name='profile'),
]
+202
View File
@@ -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.'
})