diff --git a/gratipay/models/participant/__init__.py b/gratipay/models/participant/__init__.py index 483ffacd85..6158e000ef 100644 --- a/gratipay/models/participant/__init__.py +++ b/gratipay/models/participant/__init__.py @@ -1077,7 +1077,7 @@ def profile_url(self): return '{base_url}/{username}/'.format(**locals()) - def get_teams(self, only_approved=False, cursor=None): + def get_teams(self, only_approved=False, only_open=False, cursor=None): """Return a list of teams this user is an owner or member of. """ teams = (cursor or self.db).all(""" @@ -1088,10 +1088,12 @@ def get_teams(self, only_approved=False, cursor=None): SELECT teams.*::teams FROM teams WHERE id IN ( SELECT team_id FROM current_takes WHERE participant_id=%s ) - """, (self.username, self.id) - ) + """, (self.username, self.id)) + if only_approved: teams = [t for t in teams if t.is_approved] + if only_open: + teams = [t for t in teams if not t.is_closed] return teams diff --git a/gratipay/models/team/__init__.py b/gratipay/models/team/__init__.py index 7ba0a6120c..ae14e74131 100644 --- a/gratipay/models/team/__init__.py +++ b/gratipay/models/team/__init__.py @@ -33,7 +33,8 @@ def slugize(name): return slug -class Team(Model, mixins.Available, mixins.Membership, mixins.Takes, mixins.TipMigration): +class Team(Model, mixins.Available, mixins.Closing, mixins.Membership, mixins.Takes, + mixins.TipMigration): """Represent a Gratipay team. """ diff --git a/gratipay/models/team/mixins/__init__.py b/gratipay/models/team/mixins/__init__.py index 90e544cf4c..e7635c01d2 100644 --- a/gratipay/models/team/mixins/__init__.py +++ b/gratipay/models/team/mixins/__init__.py @@ -1,6 +1,7 @@ from .available import AvailableMixin as Available +from .closing import ClosingMixin as Closing from .membership import MembershipMixin as Membership from .takes import TakesMixin as Takes from .tip_migration import TipMigrationMixin as TipMigration -__all__ = ['Available', 'Membership', 'Takes', 'TipMigration'] +__all__ = ['Available', 'Closing', 'Membership', 'Takes', 'TipMigration'] diff --git a/gratipay/models/team/mixins/closing.py b/gratipay/models/team/mixins/closing.py new file mode 100644 index 0000000000..7ae3e517e7 --- /dev/null +++ b/gratipay/models/team/mixins/closing.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals + +from gratipay.models import add_event + + +class ClosingMixin(object): + """This mixin implements team closing. + """ + + #: Whether the team is closed or not. + + is_closed = False + + + def close(self): + """Close the team account. + """ + with self.db.get_cursor() as cursor: + cursor.run("UPDATE teams SET is_closed=true WHERE id=%s", (self.id,)) + add_event(cursor, 'team', dict(id=self.id, action='set', values=dict(is_closed=True))) + self.set_attributes(is_closed=True) diff --git a/gratipay/testing/harness.py b/gratipay/testing/harness.py index 0a5b0a56ea..2916d1c7d6 100644 --- a/gratipay/testing/harness.py +++ b/gratipay/testing/harness.py @@ -155,6 +155,8 @@ def make_team(self, *a, **kw): _kw['slug_lower'] = _kw['slug'].lower() if 'is_approved' not in _kw: _kw['is_approved'] = False + if 'is_closed' not in _kw: + _kw['is_closed'] = False if 'available' not in _kw: _kw['available'] = 0 @@ -169,9 +171,9 @@ def make_team(self, *a, **kw): team = self.db.one(""" INSERT INTO teams (slug, slug_lower, name, homepage, product_or_service, - onboarding_url, owner, is_approved, available) + onboarding_url, owner, is_approved, is_closed, available) VALUES (%(slug)s, %(slug_lower)s, %(name)s, %(homepage)s, %(product_or_service)s, - %(onboarding_url)s, %(owner)s, %(is_approved)s, + %(onboarding_url)s, %(owner)s, %(is_approved)s, %(is_closed)s, %(available)s) RETURNING teams.*::teams """, _kw) diff --git a/js/gratipay/edit_team.js b/js/gratipay/edit_team.js index 09cc28dda7..2e251f3a43 100644 --- a/js/gratipay/edit_team.js +++ b/js/gratipay/edit_team.js @@ -1,14 +1,15 @@ Gratipay.edit_team = {} Gratipay.edit_team.initForm = function() { - $form = $("#edit-team"); - $buttons = $form.find("button"); // submit and cancel btns - $form.submit(Gratipay.edit_team.submitForm); + $('#edit-team').submit(Gratipay.edit_team.submitForm); + $('#close-team').submit(function() { return confirm('Really close project?') }); } Gratipay.edit_team.submitForm = function(e) { e.preventDefault(); + var $form = $("#edit-team"); + var $buttons = $form.find("button"); var data = new FormData($form[0]); $buttons.prop("disabled", true); @@ -27,4 +28,4 @@ Gratipay.edit_team.submitForm = function(e) { }, error: [Gratipay.error, function () { $buttons.prop("disabled", false); }] }); -} \ No newline at end of file +} diff --git a/scss/components/danger-zone.scss b/scss/components/danger-zone.scss new file mode 100644 index 0000000000..1870fa0c68 --- /dev/null +++ b/scss/components/danger-zone.scss @@ -0,0 +1,14 @@ +.danger-zone { + margin-top: 64px; + border: 1px solid $red; + @include border-radius(5px); + padding: 20px; + h2 { + margin: 0 0 10px; + padding: 0; + color: $red; + } + button { + background: $red; + } +} diff --git a/scss/components/long-form.scss b/scss/components/long-form.scss index 0f4ae26581..e847028677 100644 --- a/scss/components/long-form.scss +++ b/scss/components/long-form.scss @@ -113,19 +113,4 @@ input.invalid:focus + .invalid-msg { display: block; } - - .danger-zone { - margin-top: 64px; - border: 1px solid $red; - @include border-radius(5px); - padding: 20px; - h2 { - margin: 0 0 10px; - padding: 0; - color: $red; - } - button { - background: $red; - } - } } diff --git a/scss/elements/buttons-knobs.scss b/scss/elements/buttons-knobs.scss index bceec29c1c..3cf1222017 100644 --- a/scss/elements/buttons-knobs.scss +++ b/scss/elements/buttons-knobs.scss @@ -48,18 +48,6 @@ button.selected:hover, button.selected.drag, background: $darker-green; } -button.close-account { - font-weight: normal; - background: none; - color: $red; - border: 1px solid $red; - &:hover { - background: $red; - color: white; - text-decoration: none; - } -} - .buttons button { margin-top: 3px; } diff --git a/templates/team-listing.html b/templates/team-listing.html index 51807d940e..f688395eff 100644 --- a/templates/team-listing.html +++ b/templates/team-listing.html @@ -1,16 +1,17 @@ -{% set approved_teams = participant.get_teams(only_approved=True) %} +{% set unprivileged = not(participant == user.participant or user.ADMIN) %} +{% set approved_open_teams = participant.get_teams(unprivileged, unprivileged) %} -{% if (user.ADMIN or user.participant == participant) and approved_teams %} +{% if (user.ADMIN or user.participant == participant) and approved_open_teams %}

{{ _("Projects") }}

-{% elif approved_teams %} +{% elif approved_open_teams %}

{{ _("Projects") }}

diff --git a/tests/py/test_close.py b/tests/py/test_close.py index 971c7f294d..e70dcb3b7f 100644 --- a/tests/py/test_close.py +++ b/tests/py/test_close.py @@ -60,22 +60,7 @@ def test_close_page_shows_a_message_to_team_owners(self): alice = self.make_participant('alice', claimed_time='now') self.make_team('A', alice) body = self.client.GET('/~alice/settings/close', auth_as='alice').body - assert 'You are the owner of the A team.' in body - - def test_close_page_shows_a_message_to_owners_of_two_teams(self): - alice = self.make_participant('alice', claimed_time='now') - self.make_team('A', alice) - self.make_team('B', alice) - body = self.client.GET('/~alice/settings/close', auth_as='alice').body - assert 'You are the owner of the A and B teams.' in body - - def test_close_page_shows_a_message_to_owners_of_three_teams(self): - alice = self.make_participant('alice', claimed_time='now') - self.make_team('A', alice) - self.make_team('B', alice) - self.make_team('C', alice) - body = self.client.GET('/~alice/settings/close', auth_as='alice').body - assert 'You are the owner of the A, B and C teams.' in body + assert 'Please close the following projects first:' in body # cpi - clear_payment_instructions diff --git a/tests/py/test_pages.py b/tests/py/test_pages.py index 30a3b6b4c0..58431dd9a1 100644 --- a/tests/py/test_pages.py +++ b/tests/py/test_pages.py @@ -266,3 +266,42 @@ def test_your_payment_template_basically_works(self): self.make_team(is_approved=True) self.make_participant('alice') assert 'your-payment' in self.client.GET('/TheEnterprise/', auth_as='alice').body + + +class TestTeamListingTemplate(Harness): + + def setUp(self): + self.make_participant('Q', claimed_time='now', is_admin=True) + self.make_participant('Rambo', claimed_time='now') + + def check(self, auth_as=None, expected=True): + body = self.client.GET('/~picard/', auth_as=auth_as).body.decode('utf8') + assert ('The Enterprise' in body) is expected + + def test_includes_approved_open_team_for_everyone(self): + self.make_team(is_approved=True, is_closed=False) + self.check('picard') + self.check('Q') + self.check('Rambo') + self.check() + + def test_includes_approved_closed_team_for_owner_and_admin(self): + self.make_team(is_approved=True, is_closed=True) + self.check('picard') + self.check('Q') + self.check('Rambo', False) + self.check(None, False) + + def test_includes_unapproved_open_team_for_owner_and_admin(self): + self.make_team(is_approved=False, is_closed=True) + self.check('picard') + self.check('Q') + self.check('Rambo', False) + self.check(None, False) + + def test_includes_unapproved_closed_team_for_owner_and_admin(self): + self.make_team(is_approved=False, is_closed=False) + self.check('picard') + self.check('Q') + self.check('Rambo', False) + self.check(None, False) diff --git a/tests/py/test_participant.py b/tests/py/test_participant.py index bf41ed86d2..07550c8c10 100644 --- a/tests/py/test_participant.py +++ b/tests/py/test_participant.py @@ -257,11 +257,19 @@ def test_get_teams_can_get_only_approved_teams(self): self.make_team('The Stargazer', owner=picard, is_approved=False) assert [t.slug for t in picard.get_teams(only_approved=True)] == ['TheEnterprise'] + def test_get_teams_can_get_only_open_teams(self): + self.make_team() + picard = P('picard') + self.make_team('The Stargazer', owner=picard, is_closed=True) + assert [t.slug for t in picard.get_teams(only_open=True)] == ['TheEnterprise'] + def test_get_teams_can_get_all_teams(self): self.make_team(is_approved=True) picard = P('picard') self.make_team('The Stargazer', owner=picard, is_approved=False) - assert [t.slug for t in picard.get_teams()] == ['TheEnterprise', 'TheStargazer'] + self.make_team('The Trident', owner=picard, is_approved=False, is_closed=True) + assert [t.slug for t in picard.get_teams()] == \ + ['TheEnterprise', 'TheStargazer', 'TheTrident'] # giving diff --git a/tests/py/test_team_closing.py b/tests/py/test_team_closing.py new file mode 100644 index 0000000000..29ec4a15fa --- /dev/null +++ b/tests/py/test_team_closing.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals + +from gratipay.testing import Harness, T + + +class TestTeamClosing(Harness): + + def test_teams_can_be_closed_via_python(self): + team = self.make_team() + team.close() + assert team.is_closed + + def test_teams_can_be_closed_via_http(self): + self.make_team() + response = self.client.PxST('/TheEnterprise/edit/close', auth_as='picard') + assert response.headers['Location'] == '/~picard/' + assert response.code == 302 + assert T('TheEnterprise').is_closed + + def test_but_not_by_anon(self): + self.make_team() + response = self.client.PxST('/TheEnterprise/edit/close') + assert response.code == 401 + + def test_nor_by_turkey(self): + self.make_participant('turkey') + self.make_team() + response = self.client.PxST('/TheEnterprise/edit/close', auth_as='turkey') + assert response.code == 403 + + def test_admin_is_cool_though(self): + self.make_participant('Q', is_admin=True) + self.make_team() + response = self.client.PxST('/TheEnterprise/edit/close', auth_as='Q') + assert response.headers['Location'] == '/~Q/' + assert response.code == 302 + assert T('TheEnterprise').is_closed diff --git a/www/%team/edit/close.spt b/www/%team/edit/close.spt new file mode 100644 index 0000000000..beb1f3c00c --- /dev/null +++ b/www/%team/edit/close.spt @@ -0,0 +1,22 @@ +from aspen import Response +from gratipay.utils import get_team + +[----------------------------------------------------------------------------] + +if user.ANON: + raise Response(401, _("You need to log in to access this page.")) + +request.allow('POST') + +team = get_team(state) + +if not user.ADMIN and user.participant.username != team.owner: + raise Response(403, _("You are not authorized to access this page.")) + +if team.is_closed: + raise Response(403, _("Already closed.")) + +team.close() + +website.redirect('/~{}/'.format(user.participant.username)) +[---] diff --git a/www/%team/edit/index.html.spt b/www/%team/edit/index.html.spt index 99de9454a5..a8f68c65be 100644 --- a/www/%team/edit/index.html.spt +++ b/www/%team/edit/index.html.spt @@ -41,7 +41,7 @@ suppress_sidebar = True } -
+ @@ -63,4 +63,9 @@ suppress_sidebar = True
+
+ +

{{ _("Danger Zone") }}

+ +
{% endblock %} diff --git a/www/assets/gratipay.css.spt b/www/assets/gratipay.css.spt index 30ea3a2ebe..be26cc1214 100644 --- a/www/assets/gratipay.css.spt +++ b/www/assets/gratipay.css.spt @@ -30,6 +30,7 @@ @import "scss/components/chosen"; @import "scss/components/communities"; @import "scss/components/cta"; +@import "scss/components/danger-zone"; @import "scss/components/dropdown"; @import "scss/components/emails"; @import "scss/components/js-edit"; diff --git a/www/index.html.spt b/www/index.html.spt index d06b264e08..67ab7835cd 100644 --- a/www/index.html.spt +++ b/www/index.html.spt @@ -13,6 +13,7 @@ teams = website.db.all(""" SELECT teams.*::teams FROM teams + WHERE not is_closed ORDER BY ctime DESC """) diff --git a/www/~/%username/settings/close.spt b/www/~/%username/settings/close.spt index f4d214f2e4..ecbe299dc7 100644 --- a/www/~/%username/settings/close.spt +++ b/www/~/%username/settings/close.spt @@ -16,13 +16,9 @@ if request.method == 'POST': else: participant.close() website.redirect('/~%s/' % participant.username) -teams = participant.get_teams() -nteams = len(teams) -if nteams > 1: - teams = ', '.join([t.name for t in teams[:-1]]) + ' and ' + teams[-1].name + ' teams' -elif nteams == 1: - teams = teams[0].name + ' team' - +teams = website.db.all( "SELECT teams.*::teams FROM teams WHERE owner=%s AND NOT is_closed" + , (participant.username,) + ) [---] text/html {% extends "templates/base.html" %} @@ -32,9 +28,12 @@ elif nteams == 1: {% if teams %} -

You are the owner of the {{ teams }}. We don't yet support - team owners closing their accounts.

+

Please close the following projects first:

+ {% endif %} @@ -79,12 +78,12 @@ elif nteams == 1: href="https://github.com/gratipay/gratipay.com/issues/397">data retention policy).

-

Things we clear immediately include your profile statement, - the payroll you're taking, and the payments you're giving.

+

Things we clear immediately include your profile statement, + the payments you're taking, and the payments you're giving.

-

We specifically don't delete your past giving and - taking history on the site, because that information also belongs - equally to other users (the owners of the Teams you gave to and +

We specifically don't delete your past giving and + taking history on the site, because that information also belongs + equally to other users (the owners of the Teams you gave to and took from).

After you close your account, your profile page will say, diff --git a/www/~/%username/settings/index.html.spt b/www/~/%username/settings/index.html.spt index e15469ee62..6d24898aec 100644 --- a/www/~/%username/settings/index.html.spt +++ b/www/~/%username/settings/index.html.spt @@ -94,11 +94,11 @@ emails = participant.get_emails() {% endif %}

-
-

{{ _("Close") }}

-
- -
+
+

{{ _("Danger Zone") }}

+
+ +
{% endblock %}