Repository: mdn/kuma

Issue:
User acct deletion: endpoint that allows MDN users to delete acct/archive their contribs
**Related stories:**
Parent: https://github.com/mdn/sprints/issues/1922
Parent: https://github.com/mdn/sprints/issues/1947
Frontend: https://app.zenhub.com/workspaces/mdn-dev-5d6ea9fb3fe11605a63cea86/issues/mdn/kuma/6071

**Acceptance:**
- [ ] Use case 1: Endpoint allows the ability for non-contributor users to delete their account from the Modal 
- [ ] Use case 2: Endpoint allows the ability for contributor users to delete their account from the Modal, BUT retain attributions on contributions with username intact
- [ ] Use case 3: Endpoint allows the ability for contributor users to delete their account from the Modal, BUT retain attributions as 'anonymous'

**Hypothetical Incoming Data Args**: 
- MDN uid, attributions_prefers_anon, action


Discussion:
@atopal @tobinmori Who has the flow chart diagram we drew in the meeting room in Portland this summer? I'd love to get started on the back-end piece so I never have to manually deal with these requests any more. 
I just checked, and I don't have a picture of that.
Just checked too @peterbe, unfortunately, I have 2 photos of boards that aren't this one. 
Note-to-self; here's the first question: https://mozilla.invisionapp.com/share/Y8SGMKQBCW3#/screens/370705982
If I remember correctly the main question was what happens when people don't want to be associated with their content anymore. Do we delete their account and assign the content to one anonymous account or do we rename their account name to "anonymous" and leave it at that?
@escattone do you happen to the workflow image we took of this in Portland? 

If not, @peterbe will recreate the workflow and vet before implementing. 
> If I remember correctly the main question was what happens when people don't want to be associated with their content anymore. Do we delete their account and assign the content to one anonymous account or do we rename their account name to "anonymous" and leave it at that?

Yes, this is what I remember as well, thanks @atopal. I remember @atopal saying that he didn't care as to which of the choices we made, but I also remember that we, the developers, felt that it might be best to use a single anonymous account?
> @escattone do you happen to the workflow image we took of this in Portland?

I thought I did, but unfortunately I don't. 😞 
Confirming that I don't mind either way.
@atopal @escattone @tobinmori @schalkneethling Here's what I think we decided in Portland:
![Issue 5862(2)](https://user-images.githubusercontent.com/26739/69094079-b843c580-0a1d-11ea-8d6a-ec1e1fe923a6.png)


(Note-to-self; https://www.draw.io/#G1Jrf7H3YK5OtNz621dBff6M70vAzCUBTm)

3 things can happen when the user wants to delete their account:

1. They have 0 revision contributions so just wipe the user account off the face of the earth. 
2. They have >0 revision contributions, we anonymize the hell out of them. 
3. We donate all their revision contributions to a user called "anonymous", and wipe the user account off the face of the earth. 

An important implication is that, if I do this, the only thing that remains of users in option (2) is the
username. So, if I do this option, but then decide to change my mind again 6 months later, will it complain that username `peterbe` is taken and that I now need to pick `peterbe2` in my comeback? Or will it "revive" that scrubbed user account and its revisions?

Perhaps ^ question depends on trying out what actually happens and how `django-allauth` works. 

But generally, does this seem like a good plan?




@peterbe That seems like a good plan to me!

> So, if I do this option, but then decide to change my mind again 6 months later, will it complain that username peterbe is taken and that I now need to pick peterbe2 in my comeback? Or will it "revive" that scrubbed user account and its revisions?

If we tried to "revive" the account, how would we know if it's the same person? I suppose we'd have to save an email address (and so couldn't wipe everything except the username) and send a recovery code or something like that?



> @peterbe That seems like a good plan to me!
> 
> > So, if I do this option, but then decide to change my mind again 6 months later, will it complain that username peterbe is taken and that I now need to pick peterbe2 in my comeback? Or will it "revive" that scrubbed user account and its revisions?
> 
> If we tried to "revive" the account, how would we know if it's the same person? I suppose we'd have to save an email address (and so couldn't wipe everything except the username) and send a recovery code or something like that?

Yup, we would have to keep the associated email address as well so we can do the whole account recovery flow.
We won't offer a way to get an old username, if you decided to delete your MDN account. If this turns out to be frequent and contentious, we can revise our implementation. 
@peterbe yes, that diagram is what we had agreed on, IIRC. The only thing I can't remember is whether we agreed to change the username to "anonymous" or actually use the user anonymous to attribute those edits. I personally don't have an opinion either way.
> @peterbe yes, that diagram is what we had agreed on, IIRC. The only thing I can't remember is whether we agreed to change the username to "anonymous" or actually use the user anonymous to attribute those edits. I personally don't have an opinion either way.

We decided to use the single, catch-all `anonymous` user for these instances.

Resolving patch:
diff --git a/kuma/settings/common.py b/kuma/settings/common.py
--- a/kuma/settings/common.py
+++ b/kuma/settings/common.py
@@ -842,6 +842,12 @@ def pipeline_one_scss(slug, **kwargs):
         ),
         'output_filename': 'build/styles/user-banned.css',
     },
+    'user-delete': {
+        'source_filenames': (
+            'styles/user-delete.scss',
+        ),
+        'output_filename': 'build/styles/user-delete.css',
+    },
     'error-403-alternate': {
         'source_filenames': (
             'styles/error-403-alternate.scss',
diff --git a/kuma/users/forms.py b/kuma/users/forms.py
--- a/kuma/users/forms.py
+++ b/kuma/users/forms.py
@@ -199,3 +199,27 @@ def save(self, request):
             # TODO figure out why this isn't a .delay() call.
             # https://bugzilla.mozilla.org/show_bug.cgi?id=1544925
             send_recovery_email(user_pk, email, request.LANGUAGE_CODE)
+
+
+class UserDeleteForm(forms.Form):
+
+    ATTRIBUTIONS_KEEP = 'keep'
+    ATTRIBUTIONS_DONATE = 'donate'
+    ATTRIBUTIONS_CHOICES = (
+        (
+            ATTRIBUTIONS_KEEP, _(
+                'Keep my attribution for my page changes; delete my email '
+                'address and freeze my account so I can\'t ever log in again.'
+            )
+        ),
+        (
+            ATTRIBUTIONS_DONATE, _(
+                'Switch all my attributions for page changes to "Anonymous", '
+                'and delete my account.'
+            )
+        ),
+    )
+
+    attributions = forms.ChoiceField(
+        required=True, choices=ATTRIBUTIONS_CHOICES,
+        widget=forms.widgets.RadioSelect())
diff --git a/kuma/users/views.py b/kuma/users/views.py
--- a/kuma/users/views.py
+++ b/kuma/users/views.py
@@ -18,7 +18,8 @@
 from django.core.validators import validate_email, ValidationError
 from django.db import IntegrityError, transaction
 from django.db.models import Q
-from django.http import HttpResponseBadRequest, HttpResponseForbidden
+from django.http import (
+    HttpResponseBadRequest, HttpResponseForbidden, HttpResponseRedirect)
 from django.shortcuts import get_object_or_404, redirect, render
 from django.template.loader import render_to_string
 from django.utils.encoding import force_text
@@ -36,7 +37,11 @@
 from kuma.wiki.models import (Document, DocumentDeletionLog, Revision,
                               RevisionAkismetSubmission)
 
-from .forms import UserBanForm, UserEditForm, UserRecoveryEmailForm
+from .forms import (
+    UserBanForm,
+    UserDeleteForm,
+    UserEditForm,
+    UserRecoveryEmailForm)
 from .models import User, UserBan
 # we have to import the signup form here due to allauth's odd form subclassing
 # that requires providing a base form class (see ACCOUNT_SIGNUP_FORM_CLASS)
@@ -448,14 +453,95 @@ def user_edit(request, username):
 
 
 @redirect_in_maintenance_mode
+@login_required
 def user_delete(request, username):
+    user = get_object_or_404(User, username=username)
+    if user != request.user:
+        return HttpResponseForbidden()
 
-    edit_user = get_object_or_404(User, username=username)
+    def donate_attributions():
+        anon, _ = User.objects.get_or_create(username='Anonymous')
+        user.created_revisions.update(creator=anon)
+        user.created_attachment_revisions.update(creator=anon)
+
+    def scrub_user():
+        # From the User abstract class
+        user.first_name = ''
+        user.last_name = ''
+        user.email = ''
+
+        # All User attributes
+        user.timezone = ''
+        user.locale = ''
+        user.homepage = ''
+        user.title = ''
+        user.fullname = ''
+        user.organization = ''
+        user.location = ''
+        user.bio = ''
+        user.irc_nickname = ''
+        user.website_url = ''
+        user.github_url = ''
+        user.mozillians_url = ''
+        user.twitter_url = ''
+        user.linkedin_url = ''
+        user.facebook_url = ''
+        user.stackoverflow_url = ''
+        user.discourse_url = ''
+        user.stripe_customer_id = ''
+        user.save()
+
+        user.socialaccount_set.all().delete()
+        user.key_set.all().delete()
+
+    def force_logout():
+        request.session.clear()
+
+    def delete_user():
+        # Protected references to users need to be manually deleted first.
+        user.key_set.all().delete()
+
+        # Some records are worth keeping prior to deleting the user
+        # but "re-assign" to the anonymous user.
+        anon, _ = User.objects.get_or_create(username='Anonymous')
+        user.revisionakismetsubmission_set.update(sender=anon)
+        user.documentdeletionlog_set.update(user=anon)
+        user.documentspamattempt_set.update(user=anon)
+        user.documentspam_reviewed.update(reviewer=anon)
+        user.bans.update(user=anon)
+        user.bans_issued.update(by=anon)
+
+        user.delete()
+
+    revisions = Revision.objects.filter(creator=request.user)
+    context = {}
+    if request.method == 'POST':
+        # If the user has no revisions there's not choices on the form.
+        if revisions.exists():
+            form = UserDeleteForm(request.POST)
+            if form.is_valid():
+                with transaction.atomic():
+                    if form.cleaned_data['attributions'] == 'donate':
+                        donate_attributions()
+                        delete_user()
+                    elif form.cleaned_data['attributions'] == 'keep':
+                        scrub_user()
+                        force_logout()
+                    else:
+                        raise NotImplementedError(
+                            form.cleaned_data['attributions'])
+                    return HttpResponseRedirect('/')
 
-    if not edit_user.allows_editing_by(request.user):
-        return HttpResponseForbidden()
+        else:
+            delete_user()
+            return HttpResponseRedirect('/')
+    else:
+        form = UserDeleteForm()
+
+    context['form'] = form
+    context['revisions'] = revisions
 
-    return render(request, 'users/user_delete.html')
+    return render(request, 'users/user_delete.html', context)
 
 
 class SignupView(BaseSignupView):


Test patch:
diff --git a/kuma/users/tests/test_views.py b/kuma/users/tests/test_views.py
--- a/kuma/users/tests/test_views.py
+++ b/kuma/users/tests/test_views.py
@@ -15,17 +15,22 @@
 from pytz import timezone, utc
 from waffle.models import Flag
 
+from kuma.attachments.models import Attachment, AttachmentRevision
+from kuma.authkeys.models import Key
 from kuma.core.tests import assert_no_cache_header
 from kuma.core.urlresolvers import reverse
 from kuma.core.utils import to_html
 from kuma.spam.akismet import Akismet
 from kuma.spam.constants import SPAM_SUBMISSIONS_FLAG, SPAM_URL, VERIFY_URL
-from kuma.wiki.models import (Document, DocumentDeletionLog, Revision,
-                              RevisionAkismetSubmission)
+from kuma.wiki.models import (
+    Document,
+    DocumentDeletionLog,
+    DocumentSpamAttempt,
+    Revision,
+    RevisionAkismetSubmission)
 from kuma.wiki.templatetags.jinja_helpers import absolutify
 from kuma.wiki.tests import document as create_document
 
-
 from . import SampleRevisionsMixin, SocialTestMixin, user, UserTestCase
 from ..models import User, UserBan
 from ..signup import SignupForm
@@ -1295,26 +1300,251 @@ def test_account_refresh_token_saved_next_login(self):
         assert refresh_token == social_token.token_secret
 
 
-def test_missing_user_is_missing(db, client):
-    assert not User.objects.filter(username='missing').exists()
+def test_delete_user_login_always_required(db, client):
+    # Anonymous client gets redirected to sign in page.
     url = reverse('users.user_delete', kwargs={'username': 'missing'})
-    response = client.get(url)
+    response = client.get(url, HTTP_HOST=settings.WIKI_HOST)
+    assert response.status_code == 302
+    assert reverse('account_login') in response['Location']
+
+
+def test_delete_user_not_allowed(db, user_client, wiki_user_2):
+    # A username that doesn't exist.
+    url = reverse('users.user_delete', kwargs={'username': 'missing'})
+    response = user_client.get(url, HTTP_HOST=settings.WIKI_HOST)
     assert response.status_code == 404
-    assert_no_cache_header(response)
 
+    # Attempting to delete someone else.
+    url = reverse('users.user_delete', kwargs={'username': wiki_user_2.username})
+    response = user_client.get(url, HTTP_HOST=settings.WIKI_HOST)
+    assert response.status_code == 403
 
-@pytest.mark.parametrize('user_case', ['wrong_user', 'right_user'])
-def test_user_can_delete(wiki_user, wiki_user_2, user_client, user_case):
-    if user_case == 'wrong_user':
-        user = wiki_user_2
-        expected_status = 403
-    else:
-        user = wiki_user
-        expected_status = 200
-    url = reverse('users.user_delete', kwargs={'username': user.username})
-    response = user_client.get(url)
-    assert response.status_code == expected_status
-    assert_no_cache_header(response)
+
+def test_delete_user_with_no_revisions(db, user_client, wiki_user):
+    # sanity check fixtures
+    assert not Revision.objects.filter(creator=wiki_user).exists()
+    assert not AttachmentRevision.objects.filter(creator=wiki_user).exists()
+    url = reverse('users.user_delete', kwargs={'username': wiki_user.username})
+    response = user_client.get(url, HTTP_HOST=settings.WIKI_HOST)
+    assert response.status_code == 200
+    response = user_client.post(url, HTTP_HOST=settings.WIKI_HOST)
+    assert response.status_code == 302
+    assert not User.objects.filter(username=wiki_user.username).exists()
+
+
+def test_delete_user_no_revisions_misc_related(db, user_client, wiki_user):
+    Key.objects.create(user=wiki_user)
+    revision_akismet_submission = RevisionAkismetSubmission.objects.create(
+        sender=wiki_user,
+        type='spam'
+    )
+    document_deletion_log = DocumentDeletionLog.objects.create(
+        locale='any',
+        slug='Any/Thing',
+        user=wiki_user,
+        reason='...'
+    )
+    document_spam_attempt_user = DocumentSpamAttempt.objects.create(
+        user=wiki_user,
+    )
+    throwaway_user = User.objects.create(username='throwaway')
+    document_spam_attempt_reviewer = DocumentSpamAttempt.objects.create(
+        user=throwaway_user,
+        reviewer=wiki_user,
+    )
+    user_ban_by = UserBan.objects.create(
+        user=throwaway_user,
+        by=wiki_user
+    )
+    user_ban_user = UserBan.objects.create(
+        user=wiki_user,
+        by=throwaway_user,
+        is_active=False,  # otherwise it logs the user out
+    )
+
+    url = reverse('users.user_delete', kwargs={'username': wiki_user.username})
+    response = user_client.post(url, HTTP_HOST=settings.WIKI_HOST)
+    assert response.status_code == 302
+    assert not User.objects.filter(username=wiki_user.username).exists()
+
+    # These are plainly deleted
+    assert not Key.objects.all().exists()
+
+    # Moved to anonymous user
+    revision_akismet_submission.refresh_from_db()
+    assert revision_akismet_submission.sender.username == 'Anonymous'
+    document_deletion_log.refresh_from_db()
+    assert document_deletion_log.user.username == 'Anonymous'
+    document_spam_attempt_user.refresh_from_db()
+    assert document_spam_attempt_user.user.username == 'Anonymous'
+    document_spam_attempt_reviewer.refresh_from_db()
+    assert document_spam_attempt_reviewer.reviewer.username == 'Anonymous'
+    user_ban_by.refresh_from_db()
+    assert user_ban_by.by.username == 'Anonymous'
+    user_ban_user.refresh_from_db()
+    assert user_ban_user.user.username == 'Anonymous'
+
+
+def test_delete_user_donate_attributions(
+    db,
+    user_client,
+    wiki_user,
+    wiki_user_github_account,
+    root_doc
+):
+    revision = root_doc.revisions.first()
+    # Sanity check the fixture
+    assert revision.creator == wiki_user
+
+    RevisionAkismetSubmission.objects.create(sender=wiki_user)
+
+    attachment_revision = AttachmentRevision(
+        attachment=Attachment.objects.create(title='test attachment'),
+        file='some/path.ext',
+        mime_type='application/kuma',
+        creator=wiki_user,
+        title='test attachment',
+    )
+    attachment_revision.save()
+    assert AttachmentRevision.objects.filter(creator=wiki_user).exists()
+
+    url = reverse('users.user_delete', kwargs={'username': wiki_user.username})
+    response = user_client.post(url, {'attributions': 'donate'}, HTTP_HOST=settings.WIKI_HOST)
+    assert response.status_code == 302
+    assert not User.objects.filter(username=wiki_user.username).exists()
+    with pytest.raises(SocialAccount.DoesNotExist):
+        wiki_user_github_account.refresh_from_db()
+
+    revision.refresh_from_db()
+    assert revision.creator.username == 'Anonymous'
+
+    attachment_revision.refresh_from_db()
+    assert attachment_revision.creator.username == 'Anonymous'
+
+    # The user_client should now become "invalid" since its session
+    # is going to point to no user.
+    response = user_client.get(url, HTTP_HOST=settings.WIKI_HOST)
+    assert response.status_code == 302
+    assert reverse('account_login') in response['Location']
+    # Let's doublecheck that
+    whoami_url = reverse('api.v1.whoami')
+    response = user_client.get(whoami_url)
+    assert response.status_code == 200
+    assert not response.json()['username']
+    assert not response.json()['is_authenticated']
+
+
+def test_delete_user_keep_attributions(
+    db,
+    user_client,
+    wiki_user,
+    wiki_user_github_account,
+    root_doc
+):
+    # Also, pretend that the user has a rich profile
+    User.objects.filter(id=wiki_user.id).update(
+        first_name='Peter',
+        last_name='B',
+        timezone='Ocean',
+        locale='sv-SE',
+        homepage='https://www.peterbe.com',
+        title='Web Dev',
+        fullname='Peter B',
+        organization='Mozilla',
+        location='Earth',
+        bio='Doing stuff',
+        irc_nickname='pb',
+        website_url='https://www.peterbe.com',
+        github_url='github/peterbe',
+        mozillians_url='mozillians/peterbe',
+        twitter_url='twitter/peterbe',
+        linkedin_url='linkedin/peterbe',
+        facebook_url='facebook/peterbe',
+        stackoverflow_url='stackoverflow/peterbe',
+        discourse_url='discourse/peterbe',
+        stripe_customer_id='123456',
+    )
+
+    revision = root_doc.revisions.first()
+    # Sanity check the fixture
+    assert revision.creator == wiki_user
+
+    attachment_revision = AttachmentRevision(
+        attachment=Attachment.objects.create(title='test attachment'),
+        file='some/path.ext',
+        mime_type='application/kuma',
+        creator=wiki_user,
+        title='test attachment',
+    )
+    attachment_revision.save()
+    assert AttachmentRevision.objects.filter(creator=wiki_user).exists()
+
+    # Create some social logins
+    assert SocialAccount.objects.filter(user=wiki_user).exists()
+
+    # Create a RevisionAkismetSubmission
+    RevisionAkismetSubmission.objects.create(
+        revision=revision,
+        sender=wiki_user,
+        type='ham')
+
+    # Create an authentication key
+    Key.objects.create(user=wiki_user)
+
+    url = reverse('users.user_delete', kwargs={'username': wiki_user.username})
+    response = user_client.post(url, {'attributions': 'keep'}, HTTP_HOST=settings.WIKI_HOST)
+    assert response.status_code == 302
+    # Should still exist
+    assert User.objects.filter(username=wiki_user.username).exists()
+    with pytest.raises(SocialAccount.DoesNotExist):
+        wiki_user_github_account.refresh_from_db()
+
+    # Should still be associated with the user
+    revision.refresh_from_db()
+    assert revision.creator.username == wiki_user.username
+    attachment_revision.refresh_from_db()
+    assert attachment_revision.creator.username == wiki_user.username
+
+    # But the account should be clean, apart from the username.
+    wiki_user.refresh_from_db()
+    assert wiki_user.email == ''
+    assert wiki_user.first_name == ''
+    assert wiki_user.last_name == ''
+    assert wiki_user.timezone == ''
+    assert wiki_user.locale == ''
+    assert wiki_user.homepage == ''
+    assert wiki_user.title == ''
+    assert wiki_user.fullname == ''
+    assert wiki_user.organization == ''
+    assert wiki_user.location == ''
+    assert wiki_user.bio == ''
+    assert wiki_user.irc_nickname == ''
+    assert wiki_user.website_url == ''
+    assert wiki_user.github_url == ''
+    assert wiki_user.mozillians_url == ''
+    assert wiki_user.twitter_url == ''
+    assert wiki_user.linkedin_url == ''
+    assert wiki_user.facebook_url == ''
+    assert wiki_user.stackoverflow_url == ''
+    assert wiki_user.discourse_url == ''
+    assert wiki_user.stripe_customer_id == ''
+
+    assert not SocialAccount.objects.filter(user=wiki_user).exists()
+
+    # The user_client should now become "invalid" since its session
+    # is going to point to no user.
+    response = user_client.get(url, HTTP_HOST=settings.WIKI_HOST)
+    assert response.status_code == 302
+    assert reverse('account_login') in response['Location']
+    # Let's doublecheck that
+    whoami_url = reverse('api.v1.whoami')
+    response = user_client.get(whoami_url)
+    assert response.status_code == 200
+    assert not response.json()['username']
+    assert not response.json()['is_authenticated']
+
+    # There should be no Key left
+    assert not Key.objects.all().exists()
 
 
 @pytest.mark.parametrize(