Internal
Public Access
Initial commit: KeepItGoing task management server
Features: - Django-based REST API with web interface - Task management with tags, priorities, and due dates - Time tracking with start/stop timers - Subtasks support - Task filtering (all, today, upcoming, overdue, completed) - Tag-based organization with color coding - Sorting by due date and priority - Auto-assign tags when filtering - Responsive 3-pane layout (sidebar, task list, detail panel) - Task sharing between users - Mobile-responsive design with dark mode support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Notification admin configuration.
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from .models import Notification, ScheduledReminder
|
||||
|
||||
|
||||
@admin.register(Notification)
|
||||
class NotificationAdmin(admin.ModelAdmin):
|
||||
"""Admin for Notification."""
|
||||
|
||||
list_display = ['title', 'user', 'notification_type', 'is_read', 'created_at']
|
||||
list_filter = ['notification_type', 'is_read', 'created_at']
|
||||
search_fields = ['title', 'message', 'user__email']
|
||||
ordering = ['-created_at']
|
||||
raw_id_fields = ['user', 'task']
|
||||
|
||||
|
||||
@admin.register(ScheduledReminder)
|
||||
class ScheduledReminderAdmin(admin.ModelAdmin):
|
||||
"""Admin for ScheduledReminder."""
|
||||
|
||||
list_display = ['task', 'remind_at', 'is_sent', 'sent_at', 'created_at']
|
||||
list_filter = ['is_sent', 'remind_at']
|
||||
search_fields = ['task__title']
|
||||
ordering = ['remind_at']
|
||||
raw_id_fields = ['task']
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NotificationsConfig(AppConfig):
|
||||
name = 'notifications'
|
||||
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-10 00:05
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Notification',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('notification_type', models.CharField(choices=[('reminder', 'Task Reminder'), ('due_soon', 'Due Soon'), ('overdue', 'Overdue'), ('shared', 'Task Shared'), ('comment', 'Comment')], max_length=20)),
|
||||
('title', models.CharField(max_length=255)),
|
||||
('message', models.TextField()),
|
||||
('is_read', models.BooleanField(default=False)),
|
||||
('read_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'notifications',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ScheduledReminder',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('remind_at', models.DateTimeField()),
|
||||
('is_sent', models.BooleanField(default=False)),
|
||||
('sent_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'scheduled_reminders',
|
||||
'ordering': ['remind_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-10 00:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('notifications', '0001_initial'),
|
||||
('tasks', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='notification',
|
||||
name='task',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='tasks.task'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-10 00:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('notifications', '0002_initial'),
|
||||
('tasks', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='notification',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='scheduledreminder',
|
||||
name='task',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_reminders', to='tasks.task'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Notification models for KeepItGoing.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Notification(models.Model):
|
||||
"""
|
||||
Stores notifications for users.
|
||||
"""
|
||||
NOTIFICATION_TYPES = [
|
||||
('reminder', 'Task Reminder'),
|
||||
('due_soon', 'Due Soon'),
|
||||
('overdue', 'Overdue'),
|
||||
('shared', 'Task Shared'),
|
||||
('comment', 'Comment'),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='notifications'
|
||||
)
|
||||
notification_type = models.CharField(max_length=20, choices=NOTIFICATION_TYPES)
|
||||
title = models.CharField(max_length=255)
|
||||
message = models.TextField()
|
||||
task = models.ForeignKey(
|
||||
'tasks.Task',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='notifications'
|
||||
)
|
||||
is_read = models.BooleanField(default=False)
|
||||
read_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'notifications'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.notification_type}: {self.title}"
|
||||
|
||||
|
||||
class ScheduledReminder(models.Model):
|
||||
"""
|
||||
Tracks scheduled reminders for tasks.
|
||||
"""
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
task = models.ForeignKey(
|
||||
'tasks.Task',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='scheduled_reminders'
|
||||
)
|
||||
remind_at = models.DateTimeField()
|
||||
is_sent = models.BooleanField(default=False)
|
||||
sent_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'scheduled_reminders'
|
||||
ordering = ['remind_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"Reminder for {self.task.title} at {self.remind_at}"
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Notification serializers for KeepItGoing API.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from .models import Notification, ScheduledReminder
|
||||
|
||||
|
||||
class NotificationSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for notifications."""
|
||||
|
||||
task_title = serializers.CharField(source='task.title', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Notification
|
||||
fields = [
|
||||
'id', 'notification_type', 'title', 'message', 'task',
|
||||
'task_title', 'is_read', 'read_at', 'created_at'
|
||||
]
|
||||
read_only_fields = ['id', 'created_at']
|
||||
|
||||
|
||||
class ScheduledReminderSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for scheduled reminders."""
|
||||
|
||||
class Meta:
|
||||
model = ScheduledReminder
|
||||
fields = ['id', 'task', 'remind_at', 'is_sent', 'sent_at', 'created_at']
|
||||
read_only_fields = ['id', 'is_sent', 'sent_at', 'created_at']
|
||||
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
Celery tasks for notifications.
|
||||
"""
|
||||
|
||||
from celery import shared_task
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_due_reminders():
|
||||
"""
|
||||
Check for tasks due soon and send reminders.
|
||||
Runs every 5 minutes via Celery Beat.
|
||||
"""
|
||||
from tasks.models import Task
|
||||
from users.models import DeviceToken
|
||||
from .models import Notification, ScheduledReminder
|
||||
|
||||
now = timezone.now()
|
||||
|
||||
# Find scheduled reminders that need to be sent
|
||||
reminders = ScheduledReminder.objects.filter(
|
||||
remind_at__lte=now,
|
||||
is_sent=False,
|
||||
task__status__in=['pending', 'in_progress']
|
||||
).select_related('task', 'task__user')
|
||||
|
||||
for reminder in reminders:
|
||||
task = reminder.task
|
||||
user = task.user
|
||||
|
||||
# Create notification
|
||||
Notification.objects.create(
|
||||
user=user,
|
||||
notification_type='reminder',
|
||||
title=f'Reminder: {task.title}',
|
||||
message=f'Task "{task.title}" is due soon.',
|
||||
task=task,
|
||||
)
|
||||
|
||||
# Send push notifications
|
||||
send_push_to_user.delay(
|
||||
user_id=str(user.id),
|
||||
title=f'Reminder: {task.title}',
|
||||
body=f'Task is due soon.',
|
||||
data={'task_id': str(task.id)}
|
||||
)
|
||||
|
||||
# Mark as sent
|
||||
reminder.is_sent = True
|
||||
reminder.sent_at = now
|
||||
reminder.save()
|
||||
|
||||
|
||||
@shared_task
|
||||
def check_overdue_tasks():
|
||||
"""
|
||||
Check for overdue tasks and notify users.
|
||||
Runs daily.
|
||||
"""
|
||||
from tasks.models import Task
|
||||
from .models import Notification
|
||||
|
||||
today = timezone.now().date()
|
||||
|
||||
# Find overdue tasks that haven't been notified today
|
||||
overdue_tasks = Task.objects.filter(
|
||||
due_date__lt=today,
|
||||
status__in=['pending', 'in_progress']
|
||||
).select_related('user')
|
||||
|
||||
for task in overdue_tasks:
|
||||
# Check if we already sent an overdue notification today
|
||||
existing = Notification.objects.filter(
|
||||
user=task.user,
|
||||
task=task,
|
||||
notification_type='overdue',
|
||||
created_at__date=today
|
||||
).exists()
|
||||
|
||||
if not existing:
|
||||
Notification.objects.create(
|
||||
user=task.user,
|
||||
notification_type='overdue',
|
||||
title=f'Overdue: {task.title}',
|
||||
message=f'Task "{task.title}" is overdue.',
|
||||
task=task,
|
||||
)
|
||||
|
||||
send_push_to_user.delay(
|
||||
user_id=str(task.user.id),
|
||||
title=f'Overdue: {task.title}',
|
||||
body='This task is overdue.',
|
||||
data={'task_id': str(task.id)}
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_push_to_user(user_id, title, body, data=None):
|
||||
"""
|
||||
Send push notification to all user devices.
|
||||
"""
|
||||
from django.contrib.auth import get_user_model
|
||||
from users.models import DeviceToken
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
except User.DoesNotExist:
|
||||
return
|
||||
|
||||
if not user.push_notifications:
|
||||
return
|
||||
|
||||
tokens = DeviceToken.objects.filter(user=user, is_active=True)
|
||||
|
||||
for token in tokens:
|
||||
if token.platform == 'android':
|
||||
send_fcm_notification.delay(token.token, title, body, data)
|
||||
elif token.platform == 'web':
|
||||
send_web_push_notification.delay(token.token, title, body, data)
|
||||
elif token.platform == 'desktop':
|
||||
# Desktop notifications are handled via WebSocket
|
||||
pass
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_fcm_notification(token, title, body, data=None):
|
||||
"""Send FCM notification to Android device."""
|
||||
from django.conf import settings
|
||||
|
||||
# Only send if FCM is configured
|
||||
if not getattr(settings, 'FCM_CREDENTIALS_PATH', None):
|
||||
return
|
||||
|
||||
try:
|
||||
import firebase_admin
|
||||
from firebase_admin import credentials, messaging
|
||||
|
||||
# Initialize Firebase if not already done
|
||||
if not firebase_admin._apps:
|
||||
cred = credentials.Certificate(settings.FCM_CREDENTIALS_PATH)
|
||||
firebase_admin.initialize_app(cred)
|
||||
|
||||
message = messaging.Message(
|
||||
notification=messaging.Notification(
|
||||
title=title,
|
||||
body=body,
|
||||
),
|
||||
data=data or {},
|
||||
token=token,
|
||||
)
|
||||
|
||||
messaging.send(message)
|
||||
|
||||
except Exception as e:
|
||||
# Log error but don't fail
|
||||
print(f"FCM notification failed: {e}")
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_web_push_notification(subscription_info, title, body, data=None):
|
||||
"""Send Web Push notification."""
|
||||
from django.conf import settings
|
||||
|
||||
vapid_private_key = getattr(settings, 'VAPID_PRIVATE_KEY', None)
|
||||
vapid_email = getattr(settings, 'VAPID_ADMIN_EMAIL', None)
|
||||
|
||||
if not vapid_private_key or not vapid_email:
|
||||
return
|
||||
|
||||
try:
|
||||
from pywebpush import webpush, WebPushException
|
||||
import json
|
||||
|
||||
webpush(
|
||||
subscription_info=json.loads(subscription_info),
|
||||
data=json.dumps({
|
||||
'title': title,
|
||||
'body': body,
|
||||
'data': data or {}
|
||||
}),
|
||||
vapid_private_key=vapid_private_key,
|
||||
vapid_claims={'sub': f'mailto:{vapid_email}'}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Web push notification failed: {e}")
|
||||
|
||||
|
||||
@shared_task
|
||||
def schedule_task_reminder(task_id):
|
||||
"""
|
||||
Schedule a reminder for a task based on its due date and user preferences.
|
||||
Called when a task is created or updated.
|
||||
"""
|
||||
from tasks.models import Task
|
||||
from .models import ScheduledReminder
|
||||
|
||||
try:
|
||||
task = Task.objects.get(id=task_id)
|
||||
except Task.DoesNotExist:
|
||||
return
|
||||
|
||||
# Remove existing scheduled reminders for this task
|
||||
ScheduledReminder.objects.filter(task=task, is_sent=False).delete()
|
||||
|
||||
# Only schedule if task has a reminder time or due date
|
||||
if task.reminder_at:
|
||||
ScheduledReminder.objects.create(
|
||||
task=task,
|
||||
remind_at=task.reminder_at
|
||||
)
|
||||
elif task.due_date:
|
||||
# Default reminder based on user preference
|
||||
reminder_minutes = task.user.default_reminder_minutes
|
||||
if task.due_time:
|
||||
from datetime import datetime
|
||||
due_datetime = datetime.combine(task.due_date, task.due_time)
|
||||
due_datetime = timezone.make_aware(due_datetime)
|
||||
else:
|
||||
# Default to 9 AM on due date
|
||||
due_datetime = timezone.make_aware(
|
||||
timezone.datetime.combine(task.due_date, timezone.datetime.min.time())
|
||||
).replace(hour=9)
|
||||
|
||||
remind_at = due_datetime - timedelta(minutes=reminder_minutes)
|
||||
|
||||
if remind_at > timezone.now():
|
||||
ScheduledReminder.objects.create(
|
||||
task=task,
|
||||
remind_at=remind_at
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Notification URLs for KeepItGoing.
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
# API URLs (to be included under /api/notifications/)
|
||||
api_urlpatterns = [
|
||||
path('', views.NotificationListAPIView.as_view(), name='api-notification-list'),
|
||||
path('<uuid:notification_id>/read/', views.mark_read, name='api-notification-read'),
|
||||
path('read-all/', views.mark_all_read, name='api-notification-read-all'),
|
||||
path('unread-count/', views.unread_count, name='api-notification-unread-count'),
|
||||
path('<uuid:notification_id>/', views.delete_notification, name='api-notification-delete'),
|
||||
]
|
||||
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Notification views for KeepItGoing.
|
||||
"""
|
||||
|
||||
from django.utils import timezone
|
||||
from rest_framework import generics, permissions, status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .models import Notification
|
||||
from .serializers import NotificationSerializer
|
||||
|
||||
|
||||
class NotificationListAPIView(generics.ListAPIView):
|
||||
"""API endpoint for listing notifications."""
|
||||
|
||||
serializer_class = NotificationSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = Notification.objects.filter(user=self.request.user)
|
||||
|
||||
# Filter by read status
|
||||
is_read = self.request.query_params.get('is_read')
|
||||
if is_read is not None:
|
||||
queryset = queryset.filter(is_read=is_read.lower() == 'true')
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([permissions.IsAuthenticated])
|
||||
def mark_read(request, notification_id):
|
||||
"""Mark a notification as read."""
|
||||
try:
|
||||
notification = Notification.objects.get(
|
||||
id=notification_id,
|
||||
user=request.user
|
||||
)
|
||||
except Notification.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'Notification not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
notification.is_read = True
|
||||
notification.read_at = timezone.now()
|
||||
notification.save()
|
||||
|
||||
return Response(NotificationSerializer(notification).data)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([permissions.IsAuthenticated])
|
||||
def mark_all_read(request):
|
||||
"""Mark all notifications as read."""
|
||||
Notification.objects.filter(
|
||||
user=request.user,
|
||||
is_read=False
|
||||
).update(
|
||||
is_read=True,
|
||||
read_at=timezone.now()
|
||||
)
|
||||
|
||||
return Response({'status': 'All notifications marked as read'})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([permissions.IsAuthenticated])
|
||||
def unread_count(request):
|
||||
"""Get count of unread notifications."""
|
||||
count = Notification.objects.filter(
|
||||
user=request.user,
|
||||
is_read=False
|
||||
).count()
|
||||
|
||||
return Response({'unread_count': count})
|
||||
|
||||
|
||||
@api_view(['DELETE'])
|
||||
@permission_classes([permissions.IsAuthenticated])
|
||||
def delete_notification(request, notification_id):
|
||||
"""Delete a notification."""
|
||||
try:
|
||||
notification = Notification.objects.get(
|
||||
id=notification_id,
|
||||
user=request.user
|
||||
)
|
||||
except Notification.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'Notification not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
notification.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
Reference in New Issue
Block a user