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..74f3f3f79 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,8 @@ 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 +from utils.email import codalab_send_mail User = get_user_model() @@ -84,6 +86,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/profiles/models.py b/src/apps/profiles/models.py index 99bebaa25..12fd7a6ad 100644 --- a/src/apps/profiles/models.py +++ b/src/apps/profiles/models.py @@ -204,10 +204,45 @@ def delete(self, *args, **kwargs): self.is_deleted = True self.deleted_at = now() - # Anonymize personal data - # TODO add all personal data that needs to be anonymized - self.email = f"deleted_{uuid.uuid4()}@domain.com" + # Anonymize or removed personal data + + # 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() 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 6ea14a1d1..bcaa6e603 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,87 @@ 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): + user_email = user.email + # Send notice email to admins + send_user_deletion_notice_to_admin(user) + + # Soft delete the user + user.delete() + + # Send notice to user + send_user_deletion_confirmed(user_email) + + 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 @@ -339,3 +425,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 489a01273..ae6a518bb 100644 --- a/src/static/js/ours/client.js +++ b/src/static/js/ours/client.js @@ -376,5 +376,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/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 %}