Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions backend/api/management/commands/delete_expired_accounts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from django.core.management.base import BaseCommand
from django.utils import timezone
from datetime import timedelta
from api.models import CustomUser


class Command(BaseCommand):
help = 'Delete user accounts that requested deletion 7+ days ago'

def handle(self, *args, **options):
cutoff = timezone.now() - timedelta(days=7)

users_to_delete = CustomUser.objects.filter(
deletion_requested_at__isnull=False,
deletion_requested_at__lte=cutoff
)

count = users_to_delete.count()

if count == 0:
self.stdout.write(self.style.SUCCESS('No accounts to delete'))
return

# Log emails before deletion (for audit trail)
for user in users_to_delete:
self.stdout.write(f'Deleting account: {user.email}')
# TODO: Send final deletion confirmation email here

# Perform CASCADE deletion
users_to_delete.delete()

self.stdout.write(
self.style.SUCCESS(f'Successfully deleted {count} account(s)')
)
18 changes: 18 additions & 0 deletions backend/api/migrations/0013_customuser_deletion_requested_at.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.1 on 2026-01-15 20:50

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('api', '0012_populate_chapter_counts'),
]

operations = [
migrations.AddField(
model_name='customuser',
name='deletion_requested_at',
field=models.DateTimeField(blank=True, help_text='When user requested account deletion', null=True),
),
]
5 changes: 5 additions & 0 deletions backend/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
is_superuser = models.BooleanField(default=False)
last_login = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(default=timezone.now)
deletion_requested_at = models.DateTimeField(
null=True,
blank=True,
help_text="When user requested account deletion"
)

objects = CustomUserManager()

Expand Down
4 changes: 2 additions & 2 deletions backend/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ def validate(self, attrs):
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = CustomUser
fields = ('id', 'email', 'is_active', 'created_at', 'last_login')
read_only_fields = ('id', 'created_at', 'last_login')
fields = ('id', 'email', 'is_active', 'created_at', 'last_login', 'deletion_requested_at')
read_only_fields = ('id', 'created_at', 'last_login', 'deletion_requested_at')


class HabitSerializer(serializers.ModelSerializer):
Expand Down
5 changes: 4 additions & 1 deletion backend/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@
# Profile management
path('profile/', views.UserProfileDetailView.as_view(), name='profile-detail'),
path('profile/avatar/', views.upload_avatar, name='upload-avatar'),

# Account deletion
path('auth/request-deletion/', views.request_account_deletion, name='request-deletion'),
path('auth/cancel-deletion/', views.cancel_account_deletion, name='cancel-deletion'),
path('auth/export-data/', views.export_user_data, name='export-data'),
# Content selection endpoints
path('translations/', views.translations_list, name='translations-list'),
path('books/', views.books_list, name='books-list'),
Expand Down
80 changes: 80 additions & 0 deletions backend/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,86 @@ def chapters_list(request):
'chapters': serializer.data
}, status=status.HTTP_200_OK)

@api_view(['POST'])
@permission_classes([IsAuthenticated])
def request_account_deletion(request):
"""Start 7-day deletion countdown after password confirmation."""
password = request.data.get('password')

if not password:
return Response({'error': 'Password is required'}, status=status.HTTP_400_BAD_REQUEST)

if not request.user.check_password(password):
return Response({'error': 'Invalid password'}, status=status.HTTP_400_BAD_REQUEST)

request.user.deletion_requested_at = timezone.now()
request.user.save()

# TODO: Send email notification (template creation deferred)

deletion_date = timezone.now() + timedelta(days=7)

return Response({
'message': 'Account deletion scheduled for 7 days from now',
'deletion_date': deletion_date.isoformat(),
'can_cancel': True
}, status=status.HTTP_200_OK)


@api_view(['POST'])
@permission_classes([IsAuthenticated])
def cancel_account_deletion(request):
"""Cancel pending account deletion."""
if not request.user.deletion_requested_at:
return Response(
{'error': 'No pending deletion request'},
status=status.HTTP_400_BAD_REQUEST
)

request.user.deletion_requested_at = None
request.user.save()

# TODO: Send cancellation confirmation email

return Response({
'message': 'Account deletion cancelled successfully'
}, status=status.HTTP_200_OK)


@api_view(['GET'])
@permission_classes([IsAuthenticated])
@ratelimit(key='user', rate='10/h', method='GET')
def export_user_data(request):
"""Export all user data as JSON for GDPR compliance."""
user = request.user

data = {
'user': {
'email': user.email,
'created_at': user.created_at.isoformat(),
'email_verified': user.email_verified,
},
'profile': {},
'habits': list(user.habits.values('habit', 'frequency', 'purpose', 'time', 'location', 'skipped')),
'study_notes': list(user.study_notes.values('id', 'verse_reference', 'content', 'created_at', 'updated_at')),
'recent_verses': list(user.recent_verses.values('book__name', 'chapter', 'last_accessed')),
'decks': list(user.decks.values('name', 'is_public', 'created_at')),
}

# Include profile if exists
try:
profile = user.profile
data['profile'] = {
'display_name': profile.display_name,
'default_translation': profile.default_translation.code if profile.default_translation else None,
'review_goal_per_day': profile.review_goal_per_day,
}
except:
pass

response = Response(data, status=status.HTTP_200_OK)
response['Content-Disposition'] = 'attachment; filename="user_data.json"'
return response

@api_view(['GET'])
@permission_classes([IsAuthenticated])
Expand Down
Loading