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,39 @@
|
||||
"""
|
||||
User admin configuration.
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
from .models import User, DeviceToken
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(BaseUserAdmin):
|
||||
"""Admin configuration for User model."""
|
||||
|
||||
list_display = ['email', 'username', 'first_name', 'last_name', 'is_staff', 'created_at']
|
||||
list_filter = ['is_staff', 'is_superuser', 'is_active', 'created_at']
|
||||
search_fields = ['email', 'username', 'first_name', 'last_name']
|
||||
ordering = ['-created_at']
|
||||
|
||||
fieldsets = BaseUserAdmin.fieldsets + (
|
||||
('Profile', {'fields': ('timezone', 'avatar')}),
|
||||
('Preferences', {'fields': ('default_reminder_minutes', 'email_notifications', 'push_notifications')}),
|
||||
)
|
||||
|
||||
add_fieldsets = (
|
||||
(None, {
|
||||
'classes': ('wide',),
|
||||
'fields': ('email', 'username', 'password1', 'password2'),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(DeviceToken)
|
||||
class DeviceTokenAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for DeviceToken model."""
|
||||
|
||||
list_display = ['user', 'platform', 'device_name', 'is_active', 'created_at']
|
||||
list_filter = ['platform', 'is_active']
|
||||
search_fields = ['user__email', 'device_name']
|
||||
ordering = ['-created_at']
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
name = 'users'
|
||||
@@ -0,0 +1,71 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-10 00:05
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('email', models.EmailField(max_length=254, unique=True)),
|
||||
('timezone', models.CharField(default='UTC', max_length=50)),
|
||||
('avatar', models.ImageField(blank=True, null=True, upload_to='avatars/')),
|
||||
('default_reminder_minutes', models.IntegerField(default=30)),
|
||||
('email_notifications', models.BooleanField(default=True)),
|
||||
('push_notifications', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'db_table': 'users',
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DeviceToken',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('platform', models.CharField(choices=[('android', 'Android'), ('web', 'Web'), ('desktop', 'Desktop')], max_length=20)),
|
||||
('token', models.TextField()),
|
||||
('device_name', models.CharField(blank=True, max_length=255)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='device_tokens', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'device_tokens',
|
||||
'unique_together': {('user', 'token')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
User models for KeepItGoing.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
"""
|
||||
Custom user model for KeepItGoing.
|
||||
|
||||
Uses email as the primary identifier instead of username.
|
||||
"""
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
email = models.EmailField(unique=True)
|
||||
|
||||
# Profile fields
|
||||
timezone = models.CharField(max_length=50, default='UTC')
|
||||
avatar = models.ImageField(upload_to='avatars/', null=True, blank=True)
|
||||
|
||||
# Preferences
|
||||
default_reminder_minutes = models.IntegerField(default=30)
|
||||
email_notifications = models.BooleanField(default=True)
|
||||
push_notifications = models.BooleanField(default=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
# Use email as the username field
|
||||
USERNAME_FIELD = 'email'
|
||||
REQUIRED_FIELDS = ['username']
|
||||
|
||||
class Meta:
|
||||
db_table = 'users'
|
||||
verbose_name = 'user'
|
||||
verbose_name_plural = 'users'
|
||||
|
||||
def __str__(self):
|
||||
return self.email
|
||||
|
||||
|
||||
class DeviceToken(models.Model):
|
||||
"""
|
||||
Stores push notification tokens for user devices.
|
||||
"""
|
||||
PLATFORM_CHOICES = [
|
||||
('android', 'Android'),
|
||||
('web', 'Web'),
|
||||
('desktop', 'Desktop'),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='device_tokens')
|
||||
platform = models.CharField(max_length=20, choices=PLATFORM_CHOICES)
|
||||
token = models.TextField()
|
||||
device_name = models.CharField(max_length=255, blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'device_tokens'
|
||||
unique_together = ['user', 'token']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email} - {self.platform} - {self.device_name}"
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
User serializers for KeepItGoing API.
|
||||
"""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from rest_framework import serializers
|
||||
from .models import DeviceToken
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user details."""
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'id', 'email', 'username', 'first_name', 'last_name',
|
||||
'timezone', 'avatar', 'default_reminder_minutes',
|
||||
'email_notifications', 'push_notifications',
|
||||
'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['id', 'email', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class UserRegistrationSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for user registration."""
|
||||
|
||||
password = serializers.CharField(
|
||||
write_only=True,
|
||||
required=True,
|
||||
validators=[validate_password],
|
||||
style={'input_type': 'password'}
|
||||
)
|
||||
password_confirm = serializers.CharField(
|
||||
write_only=True,
|
||||
required=True,
|
||||
style={'input_type': 'password'}
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['email', 'username', 'password', 'password_confirm', 'first_name', 'last_name']
|
||||
|
||||
def validate(self, attrs):
|
||||
if attrs['password'] != attrs['password_confirm']:
|
||||
raise serializers.ValidationError({
|
||||
'password_confirm': "Passwords do not match."
|
||||
})
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data.pop('password_confirm')
|
||||
user = User.objects.create_user(**validated_data)
|
||||
return user
|
||||
|
||||
|
||||
class ChangePasswordSerializer(serializers.Serializer):
|
||||
"""Serializer for password change."""
|
||||
|
||||
old_password = serializers.CharField(
|
||||
required=True,
|
||||
style={'input_type': 'password'}
|
||||
)
|
||||
new_password = serializers.CharField(
|
||||
required=True,
|
||||
validators=[validate_password],
|
||||
style={'input_type': 'password'}
|
||||
)
|
||||
|
||||
def validate_old_password(self, value):
|
||||
user = self.context['request'].user
|
||||
if not user.check_password(value):
|
||||
raise serializers.ValidationError("Current password is incorrect.")
|
||||
return value
|
||||
|
||||
|
||||
class DeviceTokenSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for device tokens."""
|
||||
|
||||
class Meta:
|
||||
model = DeviceToken
|
||||
fields = ['id', 'platform', 'token', 'device_name', 'is_active', 'created_at']
|
||||
read_only_fields = ['id', 'created_at']
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['user'] = self.context['request'].user
|
||||
# Update if token already exists, otherwise create
|
||||
device_token, created = DeviceToken.objects.update_or_create(
|
||||
user=validated_data['user'],
|
||||
token=validated_data['token'],
|
||||
defaults=validated_data
|
||||
)
|
||||
return device_token
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
User URLs for KeepItGoing.
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
from rest_framework_simplejwt.views import (
|
||||
TokenObtainPairView,
|
||||
TokenRefreshView,
|
||||
)
|
||||
|
||||
from . import views
|
||||
|
||||
# API URLs (to be included under /api/users/)
|
||||
api_urlpatterns = [
|
||||
path('register/', views.RegisterAPIView.as_view(), name='api-register'),
|
||||
path('profile/', views.UserProfileAPIView.as_view(), name='api-profile'),
|
||||
path('change-password/', views.ChangePasswordAPIView.as_view(), name='api-change-password'),
|
||||
path('devices/', views.DeviceTokenAPIView.as_view(), name='api-devices'),
|
||||
path('devices/<uuid:pk>/', views.DeviceTokenDeleteAPIView.as_view(), name='api-device-delete'),
|
||||
path('token/', TokenObtainPairView.as_view(), name='token-obtain-pair'),
|
||||
path('token/refresh/', TokenRefreshView.as_view(), name='token-refresh'),
|
||||
]
|
||||
|
||||
# Web URLs (to be included at root level)
|
||||
web_urlpatterns = [
|
||||
path('login/', views.LoginView.as_view(), name='login'),
|
||||
path('logout/', views.LogoutView.as_view(), name='logout'),
|
||||
path('register/', views.RegisterView.as_view(), name='register'),
|
||||
path('profile/', views.ProfileView.as_view(), name='profile'),
|
||||
]
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
User views for KeepItGoing.
|
||||
"""
|
||||
|
||||
from django.contrib.auth import get_user_model, login, logout
|
||||
from django.shortcuts import render, redirect
|
||||
from django.views import View
|
||||
from rest_framework import generics, permissions, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from .serializers import (
|
||||
UserSerializer,
|
||||
UserRegistrationSerializer,
|
||||
ChangePasswordSerializer,
|
||||
DeviceTokenSerializer,
|
||||
)
|
||||
from .models import DeviceToken
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API Views
|
||||
# =============================================================================
|
||||
|
||||
class RegisterAPIView(generics.CreateAPIView):
|
||||
"""API endpoint for user registration."""
|
||||
|
||||
queryset = User.objects.all()
|
||||
serializer_class = UserRegistrationSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
user = serializer.save()
|
||||
|
||||
# Generate tokens
|
||||
refresh = RefreshToken.for_user(user)
|
||||
|
||||
return Response({
|
||||
'user': UserSerializer(user).data,
|
||||
'tokens': {
|
||||
'refresh': str(refresh),
|
||||
'access': str(refresh.access_token),
|
||||
}
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class UserProfileAPIView(generics.RetrieveUpdateAPIView):
|
||||
"""API endpoint for user profile."""
|
||||
|
||||
serializer_class = UserSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_object(self):
|
||||
return self.request.user
|
||||
|
||||
|
||||
class ChangePasswordAPIView(APIView):
|
||||
"""API endpoint for changing password."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
serializer = ChangePasswordSerializer(
|
||||
data=request.data,
|
||||
context={'request': request}
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
request.user.set_password(serializer.validated_data['new_password'])
|
||||
request.user.save()
|
||||
|
||||
return Response({'message': 'Password changed successfully.'})
|
||||
|
||||
|
||||
class DeviceTokenAPIView(generics.ListCreateAPIView):
|
||||
"""API endpoint for managing device tokens."""
|
||||
|
||||
serializer_class = DeviceTokenSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return DeviceToken.objects.filter(user=self.request.user)
|
||||
|
||||
|
||||
class DeviceTokenDeleteAPIView(generics.DestroyAPIView):
|
||||
"""API endpoint for deleting a device token."""
|
||||
|
||||
serializer_class = DeviceTokenSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
return DeviceToken.objects.filter(user=self.request.user)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Web Views (Django Templates)
|
||||
# =============================================================================
|
||||
|
||||
class LoginView(View):
|
||||
"""Web login page."""
|
||||
|
||||
def get(self, request):
|
||||
if request.user.is_authenticated:
|
||||
return redirect('dashboard')
|
||||
return render(request, 'users/login.html')
|
||||
|
||||
def post(self, request):
|
||||
from django.contrib.auth import authenticate
|
||||
|
||||
email = request.POST.get('email')
|
||||
password = request.POST.get('password')
|
||||
|
||||
user = authenticate(request, username=email, password=password)
|
||||
|
||||
if user is not None:
|
||||
login(request, user)
|
||||
next_url = request.GET.get('next', 'dashboard')
|
||||
return redirect(next_url)
|
||||
|
||||
return render(request, 'users/login.html', {
|
||||
'error': 'Invalid email or password.'
|
||||
})
|
||||
|
||||
|
||||
class LogoutView(View):
|
||||
"""Web logout."""
|
||||
|
||||
def get(self, request):
|
||||
logout(request)
|
||||
return redirect('login')
|
||||
|
||||
def post(self, request):
|
||||
logout(request)
|
||||
return redirect('login')
|
||||
|
||||
|
||||
class RegisterView(View):
|
||||
"""Web registration page."""
|
||||
|
||||
def get(self, request):
|
||||
if request.user.is_authenticated:
|
||||
return redirect('dashboard')
|
||||
return render(request, 'users/register.html')
|
||||
|
||||
def post(self, request):
|
||||
email = request.POST.get('email')
|
||||
username = request.POST.get('username')
|
||||
password = request.POST.get('password')
|
||||
password_confirm = request.POST.get('password_confirm')
|
||||
|
||||
errors = {}
|
||||
|
||||
if password != password_confirm:
|
||||
errors['password_confirm'] = 'Passwords do not match.'
|
||||
|
||||
if User.objects.filter(email=email).exists():
|
||||
errors['email'] = 'Email already registered.'
|
||||
|
||||
if User.objects.filter(username=username).exists():
|
||||
errors['username'] = 'Username already taken.'
|
||||
|
||||
if errors:
|
||||
return render(request, 'users/register.html', {'errors': errors})
|
||||
|
||||
user = User.objects.create_user(
|
||||
email=email,
|
||||
username=username,
|
||||
password=password
|
||||
)
|
||||
login(request, user)
|
||||
return redirect('dashboard')
|
||||
|
||||
|
||||
class ProfileView(View):
|
||||
"""Web profile page."""
|
||||
|
||||
def get(self, request):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect('login')
|
||||
return render(request, 'users/profile.html')
|
||||
|
||||
def post(self, request):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect('login')
|
||||
|
||||
user = request.user
|
||||
user.first_name = request.POST.get('first_name', '')
|
||||
user.last_name = request.POST.get('last_name', '')
|
||||
user.timezone = request.POST.get('timezone', 'UTC')
|
||||
user.default_reminder_minutes = int(request.POST.get('default_reminder_minutes', 30))
|
||||
user.email_notifications = request.POST.get('email_notifications') == 'on'
|
||||
user.push_notifications = request.POST.get('push_notifications') == 'on'
|
||||
user.save()
|
||||
|
||||
return render(request, 'users/profile.html', {
|
||||
'success': 'Profile updated successfully.'
|
||||
})
|
||||
Reference in New Issue
Block a user