diff --git a/src/apps/analytics/tasks.py b/src/apps/analytics/tasks.py index d86f153c2..238916143 100644 --- a/src/apps/analytics/tasks.py +++ b/src/apps/analytics/tasks.py @@ -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() diff --git a/src/apps/api/serializers/competitions.py b/src/apps/api/serializers/competitions.py index d2c250dc5..33bbefc39 100644 --- a/src/apps/api/serializers/competitions.py +++ b/src/apps/api/serializers/competitions.py @@ -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 @@ -487,6 +488,7 @@ class Meta: 'is_bot', 'email', 'status', + 'is_deleted', ) diff --git a/src/apps/api/urls.py b/src/apps/api/urls.py index 6fc171c1c..c116104ea 100644 --- a/src/apps/api/urls.py +++ b/src/apps/api/urls.py @@ -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'), diff --git a/src/apps/api/views/profiles.py b/src/apps/api/views/profiles.py index d2068b4c0..d3a4ce289 100644 --- a/src/apps/api/views/profiles.py +++ b/src/apps/api/views/profiles.py @@ -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 @@ -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() @@ -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): diff --git a/src/apps/competitions/emails.py b/src/apps/competitions/emails.py index 7c12a1fa3..5438f545b 100644 --- a/src/apps/competitions/emails.py +++ b/src/apps/competitions/emails.py @@ -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 } @@ -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 } @@ -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 } @@ -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, diff --git a/src/apps/profiles/migrations/0014_auto_20241120_1607.py b/src/apps/profiles/migrations/0014_auto_20241120_1607.py new file mode 100644 index 000000000..b339957c9 --- /dev/null +++ b/src/apps/profiles/migrations/0014_auto_20241120_1607.py @@ -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), + ), + ] diff --git a/src/apps/profiles/models.py b/src/apps/profiles/models.py index b150e54e4..5af914f28 100644 --- a/src/apps/profiles/models.py +++ b/src/apps/profiles/models.py @@ -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) @@ -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) diff --git a/src/apps/profiles/urls_accounts.py b/src/apps/profiles/urls_accounts.py index 69f3f54ad..a3b5103b2 100644 --- a/src/apps/profiles/urls_accounts.py +++ b/src/apps/profiles/urls_accounts.py @@ -14,4 +14,6 @@ path('password_reset/done/', auth_views.PasswordResetDoneView.as_view(), name='password_reset_done'), path('reset///', views.CustomPasswordResetConfirmView.as_view(), name='password_reset_confirm'), path('reset/done/', auth_views.PasswordResetCompleteView.as_view(), name='password_reset_complete'), + path('user//account/', views.UserAccountView.as_view(), name="user_account"), + path('delete//', views.delete, name='delete'), ] diff --git a/src/apps/profiles/views.py b/src/apps/profiles/views.py index 08c697e85..68ec0cff7 100644 --- a/src/apps/profiles/views.py +++ b/src/apps/profiles/views.py @@ -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 @@ -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): @@ -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 @@ -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: @@ -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 \ No newline at end of file diff --git a/src/static/js/ours/client.js b/src/static/js/ours/client.js index c0addcc44..cf4baf563 100644 --- a/src/static/js/ours/client.js +++ b/src/static/js/ours/client.js @@ -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) + }, } diff --git a/src/static/riot/competitions/detail/participant_manager.tag b/src/static/riot/competitions/detail/participant_manager.tag index 569c8f0d8..0043b4a26 100644 --- a/src/static/riot/competitions/detail/participant_manager.tag +++ b/src/static/riot/competitions/detail/participant_manager.tag @@ -28,14 +28,15 @@ {username} {email} {is_bot} - {_.startCase(status)} + {is_deleted ? "account deleted" : _.startCase(status)} diff --git a/src/static/riot/profiles/profile_account.tag b/src/static/riot/profiles/profile_account.tag new file mode 100644 index 000000000..851293493 --- /dev/null +++ b/src/static/riot/profiles/profile_account.tag @@ -0,0 +1,110 @@ + + +
+

Delete account

+
+

Warning: Deleting your account is permanent and cannot be undone. All your personal data, settings, and content will be permanently erased, and you will lose access to all services linked to your account. Please make sure to back up any important information before proceeding.

+ +
+ + + + + + + +
\ No newline at end of file diff --git a/src/templates/base.html b/src/templates/base.html index 337eba159..70ae49396 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -148,6 +148,10 @@ Edit profile + + + Account + Notifications diff --git a/src/templates/profiles/emails/template_delete_account.html b/src/templates/profiles/emails/template_delete_account.html new file mode 100644 index 000000000..536567411 --- /dev/null +++ b/src/templates/profiles/emails/template_delete_account.html @@ -0,0 +1,24 @@ +{% extends 'emails/base_email.html' %} + +{% block content %} +

We have received your request to delete your account.

+

To proceed with the deletion of your account, please confirm your request by clicking the link below:

+

{{ protocol }}://{{ domain }}{% url 'accounts:delete' uidb64=uid token=token %}

+ +
+ +

Important Information:

+
    +
  • Once confirmed, all your personal data will be permanently deleted or anonymized, except for competitions and submissions retained under our user agreement.
  • +
  • After deletion, you will no longer be eligible for any cash prizes in ongoing or future competitions.
  • +
  • If you wish to delete any submissions, please do so before confirming your account deletion.
  • +
+ +
+ +

If you did not request this action or have changed your mind, you can safely ignore this email, and your account will remain intact.

+ +
+ +

Thank you for being part of our community.

+{% endblock %} diff --git a/src/templates/profiles/emails/template_delete_account_confirmed.html b/src/templates/profiles/emails/template_delete_account_confirmed.html new file mode 100644 index 000000000..b3489da1a --- /dev/null +++ b/src/templates/profiles/emails/template_delete_account_confirmed.html @@ -0,0 +1,7 @@ +{% extends 'emails/base_email.html' %} + +{% block content %} +

Your account has been successfully removed.

+
+

Thank you for being part of our community.

+{% endblock %} diff --git a/src/templates/profiles/emails/template_delete_account_notice.html b/src/templates/profiles/emails/template_delete_account_notice.html new file mode 100644 index 000000000..94c9f9c63 --- /dev/null +++ b/src/templates/profiles/emails/template_delete_account_notice.html @@ -0,0 +1,118 @@ +{% extends 'emails/base_email.html' %} + +{% block content %} +

User account deletion notice

+ +

You are receiving this notice because your are an administrator of the platform.

+ +
+ +

The following user has removed his account:

+
    +
  • id: {{ user.id }}
  • +
  • username: {{ user.username }}
  • +
  • email: {{ user.email }}
  • +
+ +
+ +

Summary

+ +
    +
  • Organizations: {{ organizations|length }}
  • +
  • Competitions owner: {{ competitions_organizer|length }}
  • +
  • Competitions participation: {{ competitions_participation|length }}
  • +
  • Submissions: {{ submissions|length }}
  • +
  • Data: {{ data|length }}
  • +
  • DataGroups: {{ data_groups|length }}
  • +
  • Tasks: {{ tasks|length }}
  • +
  • Queues: {{ queues|length }}
  • +
  • Posts: {{ posts|length }}
  • +
+ +

Details

+ +

Organizations the user is part of:

+ + +

Competitions the user is the owner:

+ + +

Competitions the user participated in:

+ + +

Submissions from the user:

+
    + {% for submission in submissions.all %} +
  • + {{ submission }} +
  • + {% endfor %} +
+ +

Data created by the user

+
    + {% for d in data.all %} +
  • + {{ d }} +
  • + {% endfor %} +
+ +

DataGroups created by the user

+
    + {% for data_group in data_groups.all %} +
  • {{ data_group }}
  • + {% endfor %} +
+ +

Tasks created by the user

+ + +

Queues created by the user

+
    + {% for queue in queues.all %} +
  • {{ queue }}
  • + {% endfor %} +
+ +

Posts posted by the user

+
    + {% for post in posts.all %} +
  • {{ post }}
  • + {% endfor %} +
+ +{% endblock %} diff --git a/src/templates/profiles/user_account.html b/src/templates/profiles/user_account.html new file mode 100644 index 000000000..6f833d0f8 --- /dev/null +++ b/src/templates/profiles/user_account.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% load staticfiles %} + +{% block content %} + + +{% endblock %}