Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/remove user/soft removal #1691

Open
wants to merge 5 commits into
base: develop
Choose a base branch
from
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
3 changes: 1 addition & 2 deletions src/apps/analytics/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -561,8 +561,7 @@ def update_home_page_counters():
public_competitions = Competition.objects.filter(published=True).count()

# Count active users
# TODO: do not count deleted users
users = User.objects.all().count()
users = User.objects.filter(is_deleted=False).count()

# Count all submissions
submissions = Submission.objects.all().count()
Expand Down
2 changes: 2 additions & 0 deletions src/apps/api/serializers/competitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ class CompetitionParticipantSerializer(serializers.ModelSerializer):
username = serializers.CharField(source='user.username')
is_bot = serializers.BooleanField(source='user.is_bot')
email = serializers.CharField(source='user.email')
is_deleted = serializers.BooleanField(source='user.is_deleted')

class Meta:
model = CompetitionParticipant
Expand All @@ -487,6 +488,7 @@ class Meta:
'is_bot',
'email',
'status',
'is_deleted',
)


Expand Down
3 changes: 3 additions & 0 deletions src/apps/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@
path('delete_unused_submissions/', quota.delete_unused_submissions, name="delete_unused_submissions"),
path('delete_failed_submissions/', quota.delete_failed_submissions, name="delete_failed_submissions"),

# User account
path('delete_account/', profiles.delete_account, name="delete_account"),

# Analytics
path('analytics/storage_usage_history/', analytics.storage_usage_history, name='storage_usage_history'),
path('analytics/competitions_usage/', analytics.competitions_usage, name='competitions_usage'),
Expand Down
24 changes: 23 additions & 1 deletion src/apps/api/views/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.http import HttpResponse
from rest_framework.decorators import action
from rest_framework.decorators import action, api_view
from rest_framework.exceptions import ValidationError, PermissionDenied
from rest_framework.generics import GenericAPIView, RetrieveAPIView
from rest_framework import permissions, mixins
Expand All @@ -19,6 +19,7 @@
OrganizationSerializer, MembershipSerializer, SimpleOrganizationSerializer, DeleteMembershipSerializer
from profiles.helpers import send_mail
from profiles.models import Organization, Membership
from profiles.views import send_delete_account_confirmation_mail

User = get_user_model()

Expand Down Expand Up @@ -84,6 +85,27 @@ def _get_data(user):
)


@api_view(['DELETE'])
def delete_account(request):
# Check data
user = request.user
is_username_valid = user.username == request.data["username"]
is_password_valid = user.check_password(request.data["password"])

if(is_username_valid and is_password_valid):
send_delete_account_confirmation_mail(request, user)

return Response({
"success": True,
"message": "A confirmation link has been sent to your email. Follow the instruction to finish the process"
})
else:
return Response({
"success": False,
"error": "Wrong username or password"
})


class OrganizationViewSet(mixins.CreateModelMixin,
mixins.UpdateModelMixin,
GenericViewSet):
Expand Down
10 changes: 9 additions & 1 deletion src/apps/competitions/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@


def get_organizer_emails(competition):
return [user.email for user in competition.all_organizers]
return [user.email for user in competition.all_organizers if not user.is_deleted]


def send_participation_requested_emails(participant):
if participant.user.is_deleted: return

context = {
'participant': participant
}
Expand All @@ -29,6 +31,8 @@ def send_participation_requested_emails(participant):


def send_participation_accepted_emails(participant):
if participant.user.is_deleted: return

context = {
'participant': participant
}
Expand All @@ -50,6 +54,8 @@ def send_participation_accepted_emails(participant):


def send_participation_denied_emails(participant):
if participant.user.is_deleted: return

context = {
'participant': participant
}
Expand All @@ -72,6 +78,8 @@ def send_participation_denied_emails(participant):


def send_direct_participant_email(participant, content):
if participant.user.is_deleted: return

codalab_send_markdown_email(
subject=f'A message from the admins of {participant.competition.title}',
markdown_content=content,
Expand Down
23 changes: 23 additions & 0 deletions src/apps/profiles/migrations/0014_auto_20241120_1607.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 2.2.28 on 2024-11-20 16:07

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('profiles', '0013_auto_20240304_0616'),
]

operations = [
migrations.AddField(
model_name='user',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='user',
name='is_deleted',
field=models.BooleanField(default=False),
),
]
70 changes: 70 additions & 0 deletions src/apps/profiles/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ class User(ChaHubSaveMixin, AbstractBaseUser, PermissionsMixin):
# Required for social auth and such to create users
objects = ChaHubUserManager()

# Soft deletion
is_deleted = models.BooleanField(default=False)
deleted_at = models.DateTimeField(null=True, blank=True)

def save(self, *args, **kwargs):
self.slug = slugify(self.username, allow_unicode=True)
super().save(*args, **kwargs)
Expand Down Expand Up @@ -194,6 +198,72 @@ def get_used_storage_space(self):
return storage_used


def delete(self, *args, **kwargs):
"""Soft delete the user and anonymize personal data."""
from .views import send_user_deletion_notice_to_admin, send_user_deletion_confirmed

# Send a notice to admins
send_user_deletion_notice_to_admin(self)

# Mark the user as deleted
self.is_deleted = True
self.deleted_at = now()

# Anonymize or removed personal data
user_email = self.email # keep track of the email for the end of the procedure

# Github related
self.github_uid = None
self.avatar_url = None
self.url = None
self.html_url = None
self.name = None
self.company = None
self.bio = None
if self.github_info:
self.github_info.login = None
self.github_info.avatar_url = None
self.github_info.gravatar_id = None
self.github_info.html_url = None
self.github_info.name = None
self.github_info.company = None
self.github_info.bio = None
self.github_info.location = None

# Any user attribute
self.username = f"deleted_user_{self.id}"
self.slug = f"deleted_slug_{self.id}"
self.photo = None
self.email = None
self.display_name = None
self.first_name = None
self.last_name = None
self.title = None
self.location = None
self.biography = None
self.personal_url = None
self.linkedin_url = None
self.twitter_url = None
self.github_url = None

# Queues
self.rabbitmq_username = None
self.rabbitmq_password = None

# Save the changes
self.save()

# Send a confirmation email notice to the removed user
send_user_deletion_confirmed(user_email)


def restore(self, *args, **kwargs):
"""Restore a soft-deleted user. Note that personal data remains anonymized."""
self.is_deleted = False
self.deleted_at = None
self.save()


class GithubUserInfo(models.Model):
# Required Info
uid = models.CharField(max_length=30, unique=True)
Expand Down
2 changes: 2 additions & 0 deletions src/apps/profiles/urls_accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@
path('password_reset/done/', auth_views.PasswordResetDoneView.as_view(), name='password_reset_done'),
path('reset/<uidb64>/<token>/', views.CustomPasswordResetConfirmView.as_view(), name='password_reset_confirm'),
path('reset/done/', auth_views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
path('user/<slug:username>/account/', views.UserAccountView.as_view(), name="user_account"),
path('delete/<uidb64>/<token>', views.delete, name='delete'),
]
93 changes: 92 additions & 1 deletion src/apps/profiles/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from django.shortcuts import render, redirect
from django.contrib.auth import views as auth_views
from django.contrib.auth import forms as auth_forms
from django.contrib.auth.tokens import default_token_generator
from django.contrib.auth.mixins import LoginRequiredMixin
from django.template.loader import render_to_string
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
Expand All @@ -23,6 +24,10 @@
from .models import User, Organization, Membership
from oidc_configurations.models import Auth_Organization
from .tokens import account_activation_token
from competitions.models import Competition
from datasets.models import Data, DataGroup
from tasks.models import Task
from forums.models import Post


class LoginView(auth_views.LoginView):
Expand Down Expand Up @@ -104,6 +109,80 @@ def activateEmail(request, user, to_email):
messages.error(request, f'Problem sending confirmation email to {to_email}, check if you typed it correctly.')


def send_delete_account_confirmation_mail(request, user):
mail_subject = 'Confirm Your Account Deletion Request'
message = render_to_string('profiles/emails/template_delete_account.html', {
'user': user,
'domain': get_current_site(request).domain,
'uid': urlsafe_base64_encode(force_bytes(user.pk)),
'token': default_token_generator.make_token(user),
'protocol': 'https' if request.is_secure() else 'http'
})
email = EmailMessage(mail_subject, message, to=[user.email])
if email.send():
messages.success(request, f'Dear {user.username}, please go to your email inbox and click on \
the link to complete the deletion process. *Note: Check your spam folder.')
else:
messages.error(request, f'Problem sending confirmation email.')


def send_user_deletion_notice_to_admin(user):
admin_users = User.objects.filter(Q(is_superuser=True) | Q(is_staff=True))
admin_emails = [user.email for user in admin_users]

organizations = user.organizations.all()
competitions_organizer = user.competitions.all()
competitions_participation = Competition.objects.filter(participants__user=user)
submissions = user.submission.all()
data = Data.objects.filter(created_by=user)
data_groups = DataGroup.objects.filter(created_by=user)
tasks = Task.objects.filter(created_by=user)
queues = user.queues.all()
posts = Post.objects.filter(posted_by=user)

mail_subject = f'Notice: user {user.username} removed his account'
message = render_to_string('profiles/emails/template_delete_account_notice.html', {
'user': user,
'organizations': organizations,
'competitions_organizer': competitions_organizer,
'competitions_participation': competitions_participation,
'submissions': submissions,
'data': data,
'data_groups': data_groups,
'tasks': tasks,
'queues': queues,
'posts': posts
})
email = EmailMessage(mail_subject, message, to=admin_emails)
email.send()


def send_user_deletion_confirmed(email):
mail_subject = f'Codabench: your account has been successfully removed'
message = render_to_string('profiles/emails/template_delete_account_confirmed.html')
email = EmailMessage(mail_subject, message, to=[email])
email.send()


def delete(request, uidb64, token):
try:
uid = force_str(urlsafe_base64_decode(uidb64))
user = User.objects.get(pk=uid)
except User.DoesNotExist:
user = None
messages.error(request, f"User not found.")
return redirect('accounts:user_account')
if user is not None and default_token_generator.check_token(user, token):
# Soft delete the user
user.delete()

messages.success(request, f'Your account has been removed !')
return redirect('accounts:logout')
else:
messages.error(request, f"Confirmation link is invalid or expired.")
return redirect('accounts:user_account')


def sign_up(request):

# If sign up is not enabled then redirect to login
Expand Down Expand Up @@ -181,7 +260,7 @@ def log_in(request):

# Check if the user exists
try:
user = User.objects.get(Q(username=username) | Q(email=username))
user = User.objects.get((Q(username=username) | Q(email=username)) & Q(is_deleted=False))
except User.DoesNotExist:
messages.error(request, "User does not exist!")
else:
Expand Down Expand Up @@ -339,3 +418,15 @@ def get_context_data(self, **kwargs):

class OrganizationInviteView(LoginRequiredMixin, TemplateView):
template_name = 'profiles/organization_invite.html'


class UserAccountView(LoginRequiredMixin, DetailView):
queryset = User.objects.all()
template_name = 'profiles/user_account.html'
slug_url_kwarg = 'username'
query_pk_and_slug = True

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['serialized_user'] = json.dumps(UserSerializer(self.get_object()).data)
return context
7 changes: 6 additions & 1 deletion src/static/js/ours/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -400,5 +400,10 @@ CODALAB.api = {
delete_failed_submissions: () => {
return CODALAB.api.request('DELETE', `${URLS.API}delete_failed_submissions/`)
},

/*---------------------------------------------------------------------
User Account
---------------------------------------------------------------------*/
request_delete_account: (data) => {
return CODALAB.api.request('DELETE', `${URLS.API}delete_account/`, data)
},
}
Loading