Repository: gratipay/gratipay.com

Issue:
implement account disconnect
Should be a simple `delete from elsewhere where platform='foo' and participant_id='bar'` (simple only because we're not storing events and computing state for this yet. :-( ) ... but we can't delete the last account.
## <bountysource-plugin>

The **[$150 bounty](https://www.bountysource.com/issues/316535-implement-account-disconnect?utm_campaign=plugin&utm_content=tracker%2F85909&utm_medium=issues&utm_source=github)** on this issue has been claimed at [Bountysource](https://www.bountysource.com/?utm_campaign=plugin&utm_content=tracker%2F85909&utm_medium=issues&utm_source=github).
</bountysource-plugin>



Discussion:
+1 from @JeffSpies over on #804.

+1 from @kutf on #823.

Actually, we want to leave the account in `elsewhere`, we just want to update the `participant` column. We also want to log the change.

Another one:

https://twitter.com/HairyFotr/status/322130184872546304

+1 from @blakewatters

+1 from @dansage in private email.

Just verifying the +1 above

+1 from @icyfenix.

Verified +1!

+1 from @areski via [Twitter](https://twitter.com/areskib/status/336958923439501313).

P.S. When the user has more than one linked account, I'm dealing with this by running:

```
delete from elsewhere where participant='foo' and platform='twitter';
```

+1 from @cornchz via [Twitter](https://twitter.com/cornchz/status/338934145747148800).

+1 from @cmbeelby via Twitter DM.

Thanks. Also just noticed when I reconnected to the correct account that Gittip request access to my private repo's (read and write) on bitbucket. Is this necessary? Not sure if you are even using that info.

@cmbeelby That's a Bitbucket limitation: #822. :-(

+1 from @ElijahLynn via [Twitter](https://twitter.com/ElijahLynn/status/361951919998115840).

Thanks Chad!

+1 from @jorgeam91 in private email.

+1 from @dansup in email.

+1 from @amorgner in email.

+1 from @add1sun on [Twitter](https://twitter.com/add1sun/status/369806355085160448).

+1 from @YesCT on [Twitter](https://twitter.com/YesCT/status/370572884051103744).

+1 from me; I have two Bitbucket accounts (personal and Uni) and connected the wrong one by accident; now I can't fix that.

@chris-morgan Try now?

@whit537 Thanks.

+1 from @jdorfman on [Twitter](https://twitter.com/jdorfman/status/373280384475095040).

+1 from @sc0ttkclark in [IRC](https://botbot.me/freenode/gittip/msg/5735237/).

One root cause of at least some of these cases is #801.

+1 from @prateekdayal @pjc to support@gittip.com.

+1 from @sunny via support@gittip.com.

+1 from @benweet via support@gittip.com.

+1 from @jsor via support@gittip.com.

+1 from @rcross via support@gittip.com.

+1 from @astanin on [Twitter](https://twitter.com/sastanin/status/403102235866828800).

+1 from @ric2z via support@gittip.com.

+1 from @nkavian via support@gittip.com.

+1 from @AaronM via support@gittip.com.

+1 from me (I'd give it a +2, but I don't think I can do that >:3 )

+1 from me (too)...

+1 from @christopherdwhite via Twitter https://twitter.com/chrisWhite/status/413159995685892098

+1 from @syst3ml00t via supprt@gittip.com

+1 from @Fil via private email to chad@zetaweb.com.

+1 from @nalipaz via support@gittip.com

+1 from @GeneLocklin via support@gittip.com

+1 from @fhocutt via support@gittip.com

I’ve placed a [$150 bounty](https://www.bountysource.com/issues/316535-implement-account-disconnect) on this issue if it’s fixed within a month.

+1 from @deekayen on [Twitter](https://twitter.com/davidknorman/status/429267949828849664).


Resolving patch:
diff --git a/gittip/models/participant.py b/gittip/models/participant.py
--- a/gittip/models/participant.py
+++ b/gittip/models/participant.py
@@ -1007,6 +1007,32 @@ def take_over(self, account, have_confirmation=False):
                              )
                            )
 
+    def delete_elsewhere(self, platform, user_id):
+        """Deletes account elsewhere unless the user would not be able
+        to log in anymore.
+        """
+        user_id = unicode(user_id)
+        with self.db.get_cursor() as c:
+            accounts = c.all("""
+                SELECT platform, user_id
+                  FROM elsewhere
+                 WHERE participant=%s
+                   AND platform IN %s
+            """, (self.username, AccountElsewhere.signin_platforms_names))
+            assert len(accounts) > 0
+            if not [a for a in accounts if a == (platform, user_id)]:
+                raise NonexistingElsewhere()
+            if len(accounts) == 1:
+                raise LastElsewhere()
+            c.one("""
+                DELETE FROM elsewhere
+                WHERE participant=%s
+                AND platform=%s
+                AND user_id=%s
+                RETURNING participant
+            """, (self.username, platform, user_id))
+            add_event(c, 'participant', dict(id=self.id, action='disconnect', values=dict(platform=platform, user_id=user_id)))
+
 
 class NeedConfirmation(Exception):
     """Represent the case where we need user confirmation during a merge.
@@ -1036,6 +1062,10 @@ def __nonzero__(self):
         A, B, C = self._all
         return A or C
 
+class LastElsewhere(Exception): pass
+
+class NonexistingElsewhere(Exception): pass
+
 
 def typecast(request):
     """Given a Request, raise Response or return Participant.
diff --git a/gittip/wireup.py b/gittip/wireup.py
--- a/gittip/wireup.py
+++ b/gittip/wireup.py
@@ -197,6 +197,7 @@ def is_yesish(val):
         ),
     ]
     website.signin_platforms = PlatformRegistry(signin_platforms)
+    AccountElsewhere.signin_platforms_names = tuple(p.name for p in signin_platforms)
     other_platforms =  [
         Bountysource(
             website.db,


Test patch:
diff --git a/tests/test_delete_elsewhere_json.py b/tests/test_delete_elsewhere_json.py
new file mode 100644
--- /dev/null
+++ b/tests/test_delete_elsewhere_json.py
@@ -0,0 +1,43 @@
+from __future__ import absolute_import, division, print_function, unicode_literals
+
+import json
+
+from gittip.testing import Harness
+
+
+class Tests(Harness):
+
+    def test_delete_nonexistent(self):
+        self.make_participant('alice', claimed_time='now', elsewhere='twitter')
+        response = self.client.PxST('/alice/delete-elsewhere.json', {'platform': 'twitter', 'user_id': 'nonexistent'}, auth_as='alice')
+        assert response.code == 400
+        assert "not exist" in response.body
+
+    def test_delete_last(self):
+        platform, user_id = 'twitter', '1'
+        self.make_elsewhere(platform, user_id, 'alice').opt_in('alice')
+        data = dict(platform=platform, user_id=user_id)
+        response = self.client.PxST('/alice/delete-elsewhere.json', data, auth_as='alice')
+        assert response.code == 400
+        assert "last login" in response.body
+
+    def test_delete_last_login(self):
+        platform, user_id = 'twitter', '1'
+        alice, _ = self.make_elsewhere(platform, user_id, 'alice').opt_in('alice')
+        self.make_elsewhere('venmo', '1', 'alice')
+        alice.participant.take_over(('venmo', '1'))
+        data = dict(platform=platform, user_id=user_id)
+        response = self.client.PxST('/alice/delete-elsewhere.json', data, auth_as='alice')
+        assert response.code == 400
+        assert "last login" in response.body
+
+    def test_delete_200(self):
+        platform, user_id = 'twitter', '1'
+        alice, _ = self.make_elsewhere(platform, user_id, 'alice').opt_in('alice')
+        self.make_elsewhere('github', '1', 'alice')
+        alice.participant.take_over(('github', '1'))
+        data = dict(platform=platform, user_id=user_id)
+        response = self.client.POST('/alice/delete-elsewhere.json', data, auth_as='alice')
+        assert response.code == 200
+        msg = json.loads(response.body)['msg']
+        assert "OK" in msg
diff --git a/tests/test_participant.py b/tests/test_participant.py
--- a/tests/test_participant.py
+++ b/tests/test_participant.py
@@ -6,6 +6,7 @@
 
 import psycopg2
 import pytz
+import pytest
 from aspen.utils import utcnow
 from gittip import NotSane
 from gittip.exceptions import (
@@ -17,7 +18,9 @@
     NoSelfTipping,
     BadAmount,
 )
-from gittip.models.participant import NeedConfirmation, Participant
+from gittip.models.participant import (
+    LastElsewhere, NeedConfirmation, NonexistingElsewhere, Participant
+)
 from gittip.testing import Harness
 
 
@@ -235,6 +238,31 @@ def test_connecting_unknown_account_fails(self):
         with self.assertRaises(NotSane):
             Participant.from_username('bob').take_over(('github', 'jim'))
 
+    def test_delete_elsewhere_last(self):
+        alice = Participant.from_username('alice')
+        with pytest.raises(LastElsewhere):
+            alice.delete_elsewhere('twitter', 1)
+
+    def test_delete_elsewhere_nonexisting(self):
+        alice = Participant.from_username('alice')
+        with pytest.raises(NonexistingElsewhere):
+            alice.delete_elsewhere('github', 1)
+
+    def test_delete_elsewhere(self):
+        g = self.make_elsewhere('github', 1, 'alice')
+        alice = Participant.from_username('alice')
+        alice.take_over(g)
+        # test preconditions
+        accounts = alice.get_accounts_elsewhere()
+        assert accounts['twitter'] and accounts['github']
+        # do the thing
+        alice.delete_elsewhere('twitter', 1)
+        # unit test
+        accounts = alice.get_accounts_elsewhere()
+        assert accounts.get('twitter') is None and accounts['github']
+
+
+
 
 class Tests(Harness):