diff --git a/gratipay/models/__init__.py b/gratipay/models/__init__.py index f9643705b4..4ae554ec56 100644 --- a/gratipay/models/__init__.py +++ b/gratipay/models/__init__.py @@ -14,10 +14,14 @@ from .community import Community from .country import Country from .exchange_route import ExchangeRoute +from .package import Package from .participant import Participant from .team import Team +MODELS = (AccountElsewhere, Community, Country, ExchangeRoute, Package, Participant, Team) + + @contextmanager def just_yield(obj): yield obj @@ -32,7 +36,7 @@ def __init__(self, app, *a, **kw): ``.app``. """ Postgres.__init__(self, *a, **kw) - for model in (AccountElsewhere, Community, Country, ExchangeRoute, Participant, Team): + for model in MODELS: self.register_model(model) model.app = app diff --git a/gratipay/models/package/__init__.py b/gratipay/models/package/__init__.py new file mode 100644 index 0000000000..571e2cb971 --- /dev/null +++ b/gratipay/models/package/__init__.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals + +from postgres.orm import Model + + +NPM = 'npm' # We are starting with a single package manager. If we see + # traction we will expand. + + +class Package(Model): + """Represent a gratipackage. :-) + """ + + typname = 'packages' + + def __eq__(self, other): + if not isinstance(other, Package): + return False + return self.id == other.id + + def __ne__(self, other): + if not isinstance(other, Package): + return True + return self.id != other.id + + + # Constructors + # ============ + + @classmethod + def from_id(cls, id): + """Return an existing package based on id. + """ + return cls.db.one("SELECT packages.*::packages FROM packages WHERE id=%s", (id,)) + + @classmethod + def from_names(cls, package_manager, name): + """Return an existing package based on package manager and package names. + """ + return cls.db.one("SELECT packages.*::packages FROM packages " + "WHERE package_manager=%s and name=%s", (package_manager, name)) + + + # Emails + # ====== + + def send_confirmation_email(self, address): + pass diff --git a/gratipay/testing/harness.py b/gratipay/testing/harness.py index 0fccbb98f3..43003d6f91 100644 --- a/gratipay/testing/harness.py +++ b/gratipay/testing/harness.py @@ -195,6 +195,16 @@ def make_team(self, *a, **kw): return team + def make_package(self, package_manager='npm', name='foo', description='Foo', + emails=['alice@example.com']): + """Factory for packages. + """ + return self.db.one( 'INSERT INTO packages (package_manager, name, description, emails) ' + 'VALUES (%s, %s, %s, %s) RETURNING *' + , (package_manager, name, description, emails) + ) + + def make_participant(self, username, **kw): """Factory for :py:class:`~gratipay.models.participant.Participant`. """ diff --git a/js/gratipay/emails.js b/js/gratipay/emails.js index c135826f20..99d617ce3a 100644 --- a/js/gratipay/emails.js +++ b/js/gratipay/emails.js @@ -5,7 +5,6 @@ Gratipay.emails.post = function(e) { var $this = $(this); var action = this.className; var $inputs = $('.emails button, .emails input'); - console.log($this); var address = $this.parent().data('email') || $('input.add-email').val(); $inputs.prop('disabled', true); diff --git a/js/gratipay/packages.js b/js/gratipay/packages.js new file mode 100644 index 0000000000..675a0c4614 --- /dev/null +++ b/js/gratipay/packages.js @@ -0,0 +1,29 @@ +Gratipay.packages = {}; + +Gratipay.packages.post = function(e) { + e.preventDefault(); + var $this = $(this); + var action = 'add-email-and-claim-package'; + var package_id = $('input[name=package_id]').val(); + var email = $('input[name=email]:checked').val(); + + var $inputs = $('input, button'); + $inputs.prop('disabled', true); + + $.ajax({ + url: '/~' + Gratipay.username + '/emails/modify.json', + type: 'POST', + data: {action: action, address: email, package_id: package_id}, + dataType: 'json', + success: function (msg) { + if (msg) { + Gratipay.notification(msg, 'success'); + } + $inputs.prop('disabled', false); + }, + error: [ + function () { $inputs.prop('disabled', false); }, + Gratipay.error + ], + }); +}; diff --git a/scss/components/listing.scss b/scss/components/listing.scss index 1e072ec618..85bd4686bc 100644 --- a/scss/components/listing.scss +++ b/scss/components/listing.scss @@ -1,3 +1,9 @@ +.sorry { + text-align: center; + font: normal 12px/15px $Ideal; + color: $medium-gray; +} + table.listing { width: 100%; diff --git a/scss/pages/on-npm-foo.scss b/scss/pages/on-npm-foo.scss deleted file mode 100644 index e6142d70aa..0000000000 --- a/scss/pages/on-npm-foo.scss +++ /dev/null @@ -1,7 +0,0 @@ -#on-npm-foo { - #content { - a { - font-family: $Mono; - } - } -} diff --git a/scss/pages/package.scss b/scss/pages/package.scss new file mode 100644 index 0000000000..d63f050ac8 --- /dev/null +++ b/scss/pages/package.scss @@ -0,0 +1,8 @@ +#package { + .emails { + margin: 1em 0; + li { + list-style: none; + } + } +} diff --git a/scss/pages/search.scss b/scss/pages/search.scss index 9275a7eff8..850723ae7f 100644 --- a/scss/pages/search.scss +++ b/scss/pages/search.scss @@ -16,12 +16,6 @@ } } - .sorry { - text-align: center; - font: normal 12px/15px $Ideal; - color: $medium-gray; - } - h2 { margin-top: 4em; &:first-of-type { diff --git a/tests/py/test_packages.py b/tests/py/test_packages.py new file mode 100644 index 0000000000..b97e888377 --- /dev/null +++ b/tests/py/test_packages.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals + +from gratipay.models.package import NPM, Package +from gratipay.testing import Harness + + +class TestPackage(Harness): + + def test_can_be_instantiated_from_id(self): + p = self.make_package() + assert Package.from_id(p.id).id == p.id + + def test_can_be_instantiated_from_names(self): + self.make_package() + assert Package.from_names(NPM, 'foo').name == 'foo' diff --git a/tests/py/test_www_npm_package.py b/tests/py/test_www_npm_package.py new file mode 100644 index 0000000000..f5fa219bb7 --- /dev/null +++ b/tests/py/test_www_npm_package.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals + +from gratipay.models.package import NPM +from gratipay.testing import Harness + + +class TestClaimingWorkflow(Harness): + + def setUp(self): + self.make_package() + + def test_anon_gets_signin_page_from_unclaimed(self): + body = self.client.GET('/on/npm/foo/').body + assert 'npm/foo has not been claimed' in body + assert 'with a couple clicks' in body + + def test_auth_gets_send_confirmation_page_from_unclaimed(self): + self.make_participant('bob', claimed_time='now') + body = self.client.GET('/on/npm/foo/', auth_as='bob').body + assert 'npm/foo has not been claimed' in body + assert 'using any email address' in body + assert 'alice@example.com' in body + + def test_auth_gets_multiple_options_if_present(self): + self.make_package(NPM, 'bar', 'Bar', ['alice@example.com', 'alice@example.net']) + self.make_participant('bob', claimed_time='now') + body = self.client.GET('/on/npm/bar/', auth_as='bob').body + assert 'using any email address' in body + assert 'alice@example.com' in body + assert 'alice@example.net' in body + + def test_auth_gets_something_if_no_emails(self): + self.make_package(NPM, 'bar', 'Bar', []) + self.make_participant('bob', claimed_time='now') + body = self.client.GET('/on/npm/bar/', auth_as='bob').body + assert "didn't find any email addresses" in body diff --git a/tests/ttw/test_package_claiming.py b/tests/ttw/test_package_claiming.py new file mode 100644 index 0000000000..f3eb02e969 --- /dev/null +++ b/tests/ttw/test_package_claiming.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals + +from gratipay.testing import BrowserHarness + + +class TestSendConfirmationLink(BrowserHarness): + + def check(self, choice=0): + self.make_participant('bob', claimed_time='now') + self.sign_in('bob') + self.visit('/on/npm/foo/') + self.css('input[type=radio]')[choice].click() + self.css('button')[0].click() + assert self.has_element('.notification.notification-success', 1) + assert self.has_text('Check alice@example.com for a confirmation link.') + + def test_appears_to_work(self): + self.make_package() + self.check() + + def test_works_when_there_are_multiple_addresses(self): + self.make_package(emails=['alice@example.com', 'bob@example.com']) + self.check() + + def test_can_send_to_second_email(self): + self.make_package(emails=['bob@example.com', 'alice@example.com']) + self.check(choice=1) diff --git a/www/assets/gratipay.css.spt b/www/assets/gratipay.css.spt index e78c66da88..f57d8c10bf 100644 --- a/www/assets/gratipay.css.spt +++ b/www/assets/gratipay.css.spt @@ -65,10 +65,10 @@ @import "scss/pages/history"; @import "scss/pages/identities"; @import "scss/pages/team"; +@import "scss/pages/package"; @import "scss/pages/profile-edit"; @import "scss/pages/giving"; @import "scss/pages/settings"; @import "scss/pages/on-confirm"; -@import "scss/pages/on-npm-foo"; @import "scss/pages/search"; @import "scss/pages/hall-of-fame"; diff --git a/www/on/npm/%package/index.html.spt b/www/on/npm/%package/index.html.spt index b614c57cd0..327377bb7b 100644 --- a/www/on/npm/%package/index.html.spt +++ b/www/on/npm/%package/index.html.spt @@ -2,14 +2,14 @@ import requests from aspen import Response from gratipay.utils import markdown +from gratipay.models.package import Package [---] package_name = request.path['package'] -package = website.db.one("select * from packages where package_manager='npm' " - "and name=%s", (package_name,)) +package = Package.from_names('npm', package_name) if package is None: raise Response(404) banner = package_name -page_id = "on-npm-foo" +page_id = "package" suppress_sidebar = True url = 'https://npmjs.com/package/' + package.name [---] @@ -26,5 +26,43 @@ url = 'https://npmjs.com/package/' + package.name {% endblock %} +{% block scripts %} + +{{ super() }} +{% endblock %} + {% block content %} +

{{ _( '{npm_package} has not been claimed on Gratipay.' + , npm_package=('' + 'npm/' + package_name + '')|safe + ) }}

+{% if user.ANON %} +

{{ _('Is this yours? You can claim it on Gratipay with a couple clicks:') }}

+ {% include "templates/sign-in-using.html" %} + +

{{ _('What is Gratipay?') }}

+

{{ _('Gratipay helps companies and others pay for open source.') }} + {{ _("Learn more") }}

+{% else %} +

{{ _( 'Is this yours? You can claim it on Gratipay using any email address {a}on file{_a} in the maintainers field in the npm registry.' + , a=('')|safe + , _a=''|safe + ) }} + {% if len(package.emails) == 0 %} +

{{ _("Sorry, we didn't find any email addresses on file.") }}

+ {% else %} + + + + {% endif %} +{% endif %} {% endblock %} diff --git a/www/~/%username/emails/modify.json.spt b/www/~/%username/emails/modify.json.spt index 643b854f5b..783f94c1b7 100644 --- a/www/~/%username/emails/modify.json.spt +++ b/www/~/%username/emails/modify.json.spt @@ -6,6 +6,7 @@ import re from aspen import Response from gratipay.exceptions import EmailTaken, EmailAlreadyVerified, Throttled from gratipay.utils import get_participant +from gratipay.models.package import Package # exactly one @, and at least one . after @ email_re = re.compile(r'^[^@]+@[^@]+\.[^@]+$') @@ -45,6 +46,11 @@ elif action == 'set-primary': participant.update_email(address) elif action == 'remove': participant.remove_email(address) +elif action == 'add-email-and-claim-package': + package_id = request.body['package_id'] + package = Package.from_id(package_id) + package.send_confirmation_email(address) + msg = _("Check {email} for a confirmation link.", email=address) else: raise Response(400, 'unknown action "%s"' % action)