Skip to content
This repository has been archived by the owner on Feb 8, 2018. It is now read-only.

Commit

Permalink
Merge pull request #4287 from gratipay/project-closing
Browse files Browse the repository at this point in the history
Implement project (Team) closing
  • Loading branch information
mattbk authored Jan 12, 2017
2 parents 82b7652 + 36948c0 commit e10a405
Show file tree
Hide file tree
Showing 20 changed files with 195 additions and 80 deletions.
8 changes: 5 additions & 3 deletions gratipay/models/participant/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("""
Expand 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


Expand Down
3 changes: 2 additions & 1 deletion gratipay/models/team/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""

Expand Down
3 changes: 2 additions & 1 deletion gratipay/models/team/mixins/__init__.py
Original file line number Diff line number Diff line change
@@ -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']
22 changes: 22 additions & 0 deletions gratipay/models/team/mixins/closing.py
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 4 additions & 2 deletions gratipay/testing/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down
9 changes: 5 additions & 4 deletions js/gratipay/edit_team.js
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -27,4 +28,4 @@ Gratipay.edit_team.submitForm = function(e) {
},
error: [Gratipay.error, function () { $buttons.prop("disabled", false); }]
});
}
}
14 changes: 14 additions & 0 deletions scss/components/danger-zone.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
15 changes: 0 additions & 15 deletions scss/components/long-form.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
12 changes: 0 additions & 12 deletions scss/elements/buttons-knobs.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
9 changes: 5 additions & 4 deletions templates/team-listing.html
Original file line number Diff line number Diff line change
@@ -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 %}
<h2>{{ _("Projects") }}</h2>
<ul class="team memberships">
{% for team in participant.get_teams() %}
<li><a href="/{{ team.slug }}/">{{ team.name }}</a></li>
{% endfor %}
</ul>
{% elif approved_teams %}
{% elif approved_open_teams %}
<h2>{{ _("Projects") }}</h2>
<ul class="team memberships">
{% for team in approved_teams %}
{% for team in approved_open_teams %}
<li><a href="/{{ team.slug }}/">{{ team.name }}</a></li>
{% endfor %}
</ul>
Expand Down
17 changes: 1 addition & 16 deletions tests/py/test_close.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions tests/py/test_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
10 changes: 9 additions & 1 deletion tests/py/test_participant.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions tests/py/test_team_closing.py
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions www/%team/edit/close.spt
Original file line number Diff line number Diff line change
@@ -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))
[---]
7 changes: 6 additions & 1 deletion www/%team/edit/index.html.spt
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ suppress_sidebar = True
}
</style>

<form action="edit.json" method="POST" id = "edit-team">
<form action="edit.json" method="POST" id="edit-team">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">

<label><h2>{{ _("Project Name") }}</h2></label>
Expand All @@ -63,4 +63,9 @@ suppress_sidebar = True
<button onclick="window.location='../';return false;">{{ _("Cancel") }}</button>

</form>
<form action="close" method="POST" class="danger-zone" id="close-team">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<h2>{{ _("Danger Zone") }}</h2>
<button type="submit">{{ _("Close Project") }}</button>
</form>
{% endblock %}
1 change: 1 addition & 0 deletions www/assets/gratipay.css.spt
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions www/index.html.spt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ teams = website.db.all("""

SELECT teams.*::teams
FROM teams
WHERE not is_closed
ORDER BY ctime DESC

""")
Expand Down
Loading

0 comments on commit e10a405

Please sign in to comment.