From 54db2ca27491ee1383863f724f78f7dd5c906ed2 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Wed, 7 Jun 2017 14:43:04 -0400 Subject: [PATCH] Implement discovery via package.json --- gratipay/utils/ghost.py | 162 +++++++++++++++++++++++++++ scss/pages/npm.scss | 66 +++++++++++ templates/base.html | 4 +- tests/py/test_notifications.py | 4 +- tests/py/test_www_npm_package.py | 5 +- tests/ttw/test_package_claiming.py | 4 +- tests/ttw/test_package_discovery.py | 54 +++++++++ www/assets/gratipay.css.spt | 1 + www/on/npm/%package/index.html.spt | 17 ++- www/on/npm/index.html.spt | 167 ++++++++++++++++++++++++++-- 10 files changed, 461 insertions(+), 23 deletions(-) create mode 100644 gratipay/utils/ghost.py create mode 100644 scss/pages/npm.scss create mode 100644 tests/ttw/test_package_discovery.py diff --git a/gratipay/utils/ghost.py b/gratipay/utils/ghost.py new file mode 100644 index 0000000000..ea0848aa3d --- /dev/null +++ b/gratipay/utils/ghost.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals + +PACKAGE_JSON = '''\ +{ + "name": "ghost", + "version": "1.0.0-alpha.21", + "description": "Just a blogging platform.", + "author": "Ghost Foundation", + "homepage": "http://ghost.org", + "keywords": [ + "ghost", + "blog", + "cms" + ], + "repository": { + "type": "git", + "url": "git://github.com/TryGhost/Ghost.git" + }, + "bugs": "https://github.com/TryGhost/Ghost/issues", + "contributors": "https://github.com/TryGhost/Ghost/graphs/contributors", + "license": "MIT", + "main": "./core/index", + "scripts": { + "start": "node index", + "test": "grunt validate --verbose", + "init": "yarn global add knex-migrator ember-cli grunt-cli && yarn install && grunt symlink && grunt init || true" + }, + "engines": { + "node": "^4.5.0 || ^6.9.0" + }, + "dependencies": { + "amperize": "0.3.4", + "archiver": "1.3.0", + "bcryptjs": "2.4.3", + "bluebird": "3.5.0", + "body-parser": "1.17.1", + "bookshelf": "0.10.3", + "brute-knex": "https://github.com/cobbspur/brute-knex/tarball/37439f56965b17d29bb4ff9b3f3222b2f4bd6ce3", + "bson-objectid": "1.1.5", + "chalk": "1.1.3", + "cheerio": "0.22.0", + "compression": "1.6.2", + "connect-slashes": "1.3.1", + "cookie-session": "1.2.0", + "cors": "2.8.3", + "csv-parser": "1.11.0", + "debug": "2.6.6", + "downsize": "0.0.8", + "express": "4.15.2", + "express-brute": "1.0.1", + "express-hbs": "1.0.4", + "extract-zip-fork": "1.5.1", + "fs-extra": "3.0.1", + "ghost-gql": "0.0.6", + "ghost-ignition": "2.8.11", + "ghost-storage-base": "0.0.1", + "glob": "5.0.15", + "gscan": "1.1.0", + "html-to-text": "3.2.0", + "icojs": "0.7.2", + "image-size": "0.5.2", + "intl": "1.2.5", + "intl-messageformat": "1.3.0", + "jsdom": "9.12.0", + "jsonpath": "0.2.11", + "knex": "0.12.9", + "knex-migrator": "2.0.16", + "lodash": "4.17.4", + "markdown-it": "8.3.1", + "markdown-it-footnote": "3.0.1", + "markdown-it-lazy-headers": "0.1.3", + "markdown-it-mark": "2.0.0", + "markdown-it-named-headers": "0.0.4", + "mobiledoc-dom-renderer": "0.6.5", + "moment": "2.18.1", + "moment-timezone": "0.5.13", + "multer": "1.3.0", + "mysql": "2.13.0", + "nconf": "0.8.4", + "netjet": "1.1.3", + "nodemailer": "0.7.1", + "oauth2orize": "1.8.0", + "passport": "0.3.2", + "passport-ghost": "2.3.1", + "passport-http-bearer": "1.0.1", + "passport-oauth2-client-password": "0.1.2", + "path-match": "1.2.4", + "rss": "1.2.2", + "sanitize-html": "1.14.1", + "semver": "5.3.0", + "simple-dom": "0.3.2", + "simple-html-tokenizer": "0.4.1", + "superagent": "3.5.2", + "unidecode": "0.1.8", + "uuid": "3.0.1", + "validator": "6.3.0", + "xml": "1.0.1" + }, + "optionalDependencies": { + "sqlite3": "3.1.8" + }, + "devDependencies": { + "grunt": "1.0.1", + "grunt-bg-shell": "2.3.3", + "grunt-cli": "1.2.0", + "grunt-contrib-clean": "1.0.0", + "grunt-contrib-compress": "1.3.0", + "grunt-contrib-copy": "1.0.0", + "grunt-contrib-jshint": "1.0.0", + "grunt-contrib-symlink": "^1.0.0", + "grunt-contrib-uglify": "2.0.0", + "grunt-contrib-watch": "1.0.0", + "grunt-cssnano": "2.1.0", + "grunt-docker": "0.0.11", + "grunt-express-server": "0.5.3", + "grunt-jscs": "3.0.1", + "grunt-mocha-cli": "2.1.0", + "grunt-mocha-istanbul": "5.0.2", + "grunt-shell": "1.3.1", + "grunt-subgrunt": "1.2.0", + "grunt-update-submodules": "0.4.1", + "istanbul": "0.4.5", + "jshint": "2.9.4", + "jshint-stylish": "2.2.1", + "matchdep": "1.0.1", + "minimist": "1.2.0", + "mocha": "3.3.0", + "nock": "9.0.13", + "rewire": "2.5.2", + "run-sequence": "1.2.2", + "should": "11.2.1", + "should-http": "0.1.1", + "sinon": "1.17.7", + "supertest": "3.0.0", + "tmp": "0.0.31" + }, + "greenkeeper": { + "ignore": [ + "glob", + "nodemailer", + "grunt", + "grunt-bg-shell", + "grunt-cli", + "grunt-contrib-clean", + "grunt-contrib-compress", + "grunt-contrib-copy", + "grunt-contrib-jshint", + "grunt-contrib-uglify", + "grunt-contrib-watch", + "grunt-docker", + "grunt-express-server", + "grunt-jscs", + "grunt-mocha-cli", + "grunt-mocha-istanbul", + "grunt-shell", + "grunt-subgrunt", + "grunt-update-submodules", + "sinon" + ] + } +}''' diff --git a/scss/pages/npm.scss b/scss/pages/npm.scss new file mode 100644 index 0000000000..0591cd4fc9 --- /dev/null +++ b/scss/pages/npm.scss @@ -0,0 +1,66 @@ +#npm { + + #content { + .important-thing-at-the-top { + margin-bottom: 40px; + } + .nav { + margin: 0 20% 60px; + + li { + width: 50%; + text-align: center; + display: block; + float: left; + margin: 0; + padding: 0; + a { + font: normal 24px/28px $Ideal; + display: block; + height: 64px; + padding: 18px 0 0; + margin: 0; + border-bottom: 0; + + .textwrap { + color: $black; + display: inline-block; + border-bottom: 4px solid transparent; + } + + &:hover { + background: none; + } + + &.selected { + .textwrap { + border-bottom-color: $green; + font-weight: bold; + } + } + } + &:nth-child(1) a { + padding-left: 33%; + text-align: left; + } + &:nth-child(2) a { + padding-right: 33%; + text-align: right; + } + } + } + .discovery .sorry { + margin: 40px 0; + } + + form.package-json { + margin-top: 30px; + } + + textarea { + height: 192px; + width: 100%; + font: 10px/12px $Mono; + } + } +} diff --git a/templates/base.html b/templates/base.html index 16dd90d7f7..e2161da0e4 100644 --- a/templates/base.html +++ b/templates/base.html @@ -71,18 +71,20 @@

{{ banner }}

{% endif %}{% endblock %} + {% if page_id != 'npm' %}
{{ _( "{nowrap}We have {a}integrated npm{_a} into Gratipay.{_nowrap}{_nowrap}" , nowrap=''|safe , _nowrap=''|safe - , a=''|safe + , a=''|safe , _a=''|safe ) }}
+ {% endif %}
{% block main %} diff --git a/tests/py/test_notifications.py b/tests/py/test_notifications.py index 3a8576c6e3..f516d6912d 100644 --- a/tests/py/test_notifications.py +++ b/tests/py/test_notifications.py @@ -26,5 +26,5 @@ def test_remove_notification(self): alice.remove_notification('1234') assert alice.notifications == ["abcd", "bcde"] - def test_blog_announcement(self): - assert 'integrating-npm-39333109419d">integrated' in self.client.GET('/').body + def test_star_announcement(self): + assert '/on/npm/">integrate' in self.client.GET('/').body diff --git a/tests/py/test_www_npm_package.py b/tests/py/test_www_npm_package.py index e1ea7801f9..5f465d058b 100644 --- a/tests/py/test_www_npm_package.py +++ b/tests/py/test_www_npm_package.py @@ -12,7 +12,7 @@ def setUp(self): def test_anon_gets_signin_page_from_unclaimed(self): body = self.client.GET('/on/npm/foo/').body - assert 'foo npm package:' in body + assert 'foo npm package on Gratipay:' in body def test_auth_gets_send_confirmation_page_from_unclaimed(self): self.make_participant('bob', claimed_time='now') @@ -60,6 +60,7 @@ class Bulk(Harness): def setUp(self): self.make_package() - def test_anon_gets_signin_page(self): + def test_anon_gets_payment_flow(self): body = self.client.GET('/on/npm/').body + assert 'Paste a package.json' in body assert '0 out of all 1 npm package' in body diff --git a/tests/ttw/test_package_claiming.py b/tests/ttw/test_package_claiming.py index 14501bd7a1..0eebc9c855 100644 --- a/tests/ttw/test_package_claiming.py +++ b/tests/ttw/test_package_claiming.py @@ -162,10 +162,10 @@ def setUp(self): def visit_as(self, username): self.visit('/') self.sign_in(username) - self.visit('/on/npm/') + self.visit('/on/npm/?flow=receive') def test_anon_gets_sign_in_prompt(self): - self.visit('/on/npm/') + self.visit('/on/npm/?flow=receive') assert self.css('.important-button button').text == 'Sign in / Sign up' def test_auth_without_email_gets_highlighted_link_to_email(self): diff --git a/tests/ttw/test_package_discovery.py b/tests/ttw/test_package_discovery.py new file mode 100644 index 0000000000..bb981ec9d3 --- /dev/null +++ b/tests/ttw/test_package_discovery.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals + +from gratipay.testing import BrowserHarness + + +class Tests(BrowserHarness): + + def assertDiscovery(self): + instructions = self.css('.instructions').text + assert instructions == 'Paste a package.json to find packages to pay for:' + + def test_anon_gets_discovery_page_by_default(self): + self.visit('/on/npm/') + self.assertDiscovery() + + def test_auth_also_gets_discovery_page_by_default(self): + self.make_participant('alice') + self.sign_in('alice') + self.visit('/on/npm/') + self.assertDiscovery() + + def test_pasting_a_package_json_works(self): + self.make_package(name='amperize', description='Amperize!') + mysql = self.make_package(name='mysql', description='MySQL!', emails=['bob@example.com']) + self.make_package(name='netjet', description='Netjet!', emails=['cat@example.com']) + scape = self.make_package(name='scape', description='Reject!', emails=['goat@example.com']) + self.claim_package(self.make_participant('alice'), 'amperize') + self.claim_package(self.make_participant('bob'), 'mysql') + self.claim_package(self.make_participant('goat'), 'scape') + + admin = self.make_admin() + mysql.team.update(name='MySQL') + mysql.team.update_review_status('approved', admin) + scape.team.update_review_status('rejected', admin) + + self.visit('/on/npm/') + self.css('textarea').fill('''\ + + { "dependencies": {"scape": "...", "mysql": "...", "amperize": "..."} + , "optionalDependencies": {"netjet": "...", "falafel": "..."} + } + + ''') + self.css('form.package-json button').click() + + names = [x.text for x in self.css('.listing-name')] + assert names == ['MySQL (mysql on npm)', 'scape', 'amperize', 'netjet', 'falafel'] + + statuses = [x.text[3:] for x in self.css('.listing-details .status')] + assert statuses == ['Approved', 'Rejected', 'Unreviewed', 'Unclaimed'] + + enabled = [not x.has_class('disabled') for x in self.css('td.item')] + assert enabled == [True, True, True, True, False] diff --git a/www/assets/gratipay.css.spt b/www/assets/gratipay.css.spt index cb36ae767c..b2da2dc116 100644 --- a/www/assets/gratipay.css.spt +++ b/www/assets/gratipay.css.spt @@ -68,6 +68,7 @@ @import "scss/pages/history"; @import "scss/pages/identities"; @import "scss/pages/team"; +@import "scss/pages/npm"; @import "scss/pages/package"; @import "scss/pages/profile-edit"; @import "scss/pages/giving"; diff --git a/www/on/npm/%package/index.html.spt b/www/on/npm/%package/index.html.spt index 9efb9543bb..0703212df9 100644 --- a/www/on/npm/%package/index.html.spt +++ b/www/on/npm/%package/index.html.spt @@ -51,17 +51,22 @@ if user.participant:

{{ _("No description available.") }}

{% endif %} -

- {{ _( 'Apply to accept payments for the {package_link} npm package:' - , package_link=('' + package_name + '')|safe - ) }} -

- {% if user.ANON %} +

+ {{ _( 'Claim the {package_link} npm package on Gratipay:' + , package_link=('' + package_name + '')|safe + ) }} +

{{ sign_in_using(button_class='large') }}
{% else %} +

+ {{ _( 'Apply to accept payments for the {package_link} npm package:' + , package_link=('' + package_name + '')|safe + ) }} +

+ {% if len(emails) == 0 %}

{{ _("No email addresses on file.") }}

{% else %} diff --git a/www/on/npm/index.html.spt b/www/on/npm/index.html.spt index a2bcf17065..c93b1adf41 100644 --- a/www/on/npm/index.html.spt +++ b/www/on/npm/index.html.spt @@ -1,6 +1,12 @@ -from gratipay.utils import icons +import json +from collections import OrderedDict + +from aspen import Response +from gratipay.utils import icons, tabs, listings, ghost + +GROUPS = ('dependencies', 'devDependencies', 'optionalDependencies') [---] -banner = manager = "npm" +banner = manager = page_id = "npm" suppress_sidebar = True npm_stats = website.db.one(''' select (select count(*) from teams_to_packages) as claimed_packages @@ -9,7 +15,67 @@ npm_stats = website.db.one(''' if user.participant: packages_for_claiming = user.participant.get_packages_for_claiming(manager) any_claimable = any([rec.claimed_by is None for rec in packages_for_claiming]) + +# Can't factor this out because of translations? +i18ned_statuses = { "approved": _("Approved") + , "unreviewed" : _("Unreviewed") + , "rejected": _("Rejected") + , "featured": _("Featured") + } + +flow = request.qs.get('flow') +i18ned_flows = {'give': _('Give'), 'receive' : _('Receive')} +tab_html = lambda key, tab: '{}'.format(i18ned_flows[key]) +tabs = tabs.make(tab_html, 'flow', flow, 'give', 'receive') + +discovered = [] +if request.method == 'POST': + try: + package_json = json.loads(request.body['package.json'], object_pairs_hook=OrderedDict) + except: + raise Response(400) + + # We want a single list of (source, name, package, project) tuples. Source + # is 'dependencies', 'devDependencies', or 'optionalDependencies'. We want + # name in the tuple in case there is no package. Package and team should be + # None if we don't have one. + + package_json_groups = [package_json.get(g, OrderedDict()) for g in GROUPS] + assert all(d.__class__ is OrderedDict for d in package_json_groups) + + # Load package and project (team) objects (in a single database call). + alldeps = set() + [map(alldeps.add, d) for d in package_json_groups] + known = website.db.all(''' + SELECT p.name + , p.*::packages package + , t.*::teams project + FROM packages p + LEFT JOIN teams_to_packages t2p + ON p.id = t2p.package_id + LEFT JOIN teams t + ON t2p.team_id = t.id + WHERE package_manager='npm' + AND p.name = ANY(%s) + ''', (list(alldeps),)) + known = {n:(p,t) for n,p,t in known} + + # Replace ranges with tuples. + for source, deps in zip(GROUPS, package_json_groups): + for name in deps: + package, project = known.get(name, (None, None)) + deps[name] = (source, name, package, project) + + # Regroup. + yes, no = [], [] + for group in package_json_groups: + for rec in group.values(): + project = rec[3] + (yes if project and project.is_approved else no).append(rec) + discovered = [(_("Ready to accept payments"), yes), (_("Not available to pay"), no)] + [---] +{% from "templates/nav-tabs.html" import nav_tabs with context %} {% extends "templates/base.html" %} {% block banner %} @@ -33,20 +99,100 @@ if user.participant: {% block content %} -

- {{ _('Free as in money.') }} -

+

{{ _('Free as in money.') }}

+{{ nav_tabs(tabs) }} -

- {{ _('Apply to accept payments for your npm packages:') }} -

+{% if flow == None %} +{% if discovered %} +
+{% for heading, group in discovered %} +

{{ heading }}

+{% if group %} + + {% for i, (source, name, package, project) in enumerate(group, start=1) %} + + + + {% endfor %} +
+ {% if project %} + + {% else %} + + {% endif %} +
+ + {% if project and project.name != package.name %} + + {{ _( "{project} ({package} on npm)" + , project=project.name + , package=package.name + ) }} + + {% elif project %} + + {{ package.name }} + + {% elif package %} + + {{ package.name }} + + {% else %} + {{ name }} + {% endif %} + +
+ {{ i }} + · {{ source }} + {% if project %} + · + {{ icons.STATUS_ICONS[icons.REVIEW_MAP[project.status]]|safe }}{{ i18ned_statuses[project.status] }} + + {% elif package %} + · + {{ icons.STATUS_ICONS[icons.REVIEW_MAP['rejected']]|safe }}{{ _("Unclaimed") }} + + {% endif %} + + · + {{ package.description or _("Unknown package") }} + +
+
+{% else %} +

{{ _("No packages discovered.") }}

+{% endif %} +{% endfor %} +
+{% endif %} + +
+ +

+ {{ _('Paste a package.json to find packages to pay for:') }} +

+ + -{% if user.ANON %}
- {{ sign_in_using(button_class='large') }} +
+
+ {% else %} +

{{ _('Apply to accept payments for your npm packages:') }}

+{% if user.ANON %} +
{{ sign_in_using(button_class='large') }}
+{% else %} {% if not packages_for_claiming %}

{{ _("No packages found.") }}

{% else %} @@ -120,6 +266,7 @@ if user.participant: , _a=''|safe ) }}

+ {% endif %} {% endif %}

{{ _( "{n} out of all {N} npm packages are on Gratipay."