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

Commit

Permalink
Implement package claiming
Browse files Browse the repository at this point in the history
  • Loading branch information
chadwhitacre committed Apr 17, 2017
1 parent baf8248 commit 404107c
Show file tree
Hide file tree
Showing 10 changed files with 316 additions and 69 deletions.
48 changes: 48 additions & 0 deletions gratipay/models/package/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function, unicode_literals

import uuid

from gratipay.models.team import Team
from postgres.orm import Model


Expand All @@ -10,6 +13,14 @@

class Package(Model):
"""Represent a gratipackage. :-)
Packages are entities on open source package managers; `npm
<https://www.npmjs.com/>`_ is the only one we support so far. Each package
on npm has a page on Gratipay with an URL of the form ``/on/npm/foo/``.
Packages can be claimed by Gratipay participants, at which point we create
a :py:class:`~gratipay.models.team.Team` for them under the hood so they
can start accepting payments.
"""

typname = 'packages'
Expand Down Expand Up @@ -40,3 +51,40 @@ def from_names(cls, package_manager, name):
"""
return cls.db.one("SELECT packages.*::packages FROM packages "
"WHERE package_manager=%s and name=%s", (package_manager, name))


@property
def team(self):
"""A computed attribute, the :py:class:`~gratipay.models.team.Team`
linked to this package if there is one, otherwise ``None``. Makes a
database call.
"""
return self.load_team(self.db)


def load_team(self, cursor):
"""Given a database cursor, return a
:py:class:`~gratipay.models.team.Team` if there is one linked to this
package, or ``None`` if not.
"""
return cursor.one('SELECT t.*::teams FROM teams t WHERE t.id=%s', (self.team_id,))


def get_or_create_linked_team(self, cursor, owner):
"""Given a db cursor and :py:class:`Participant`, return a
:py:class:`~gratipay.models.team.Team`.
"""
team = self.load_team(cursor)
if not team:
slug = str(uuid.uuid4()).lower()
team = Team.insert( slug=slug
, slug_lower=slug
, name=slug
, homepage=''
, product_or_service=''
, owner=owner
, _cursor=cursor
)
cursor.run('UPDATE packages SET team_id=%s WHERE id=%s', (team.id, self.id))
self.set_attributes(team_id=team.id)
return team
131 changes: 88 additions & 43 deletions gratipay/models/participant/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ def start_email_verification(self, email, *packages):
verification email for an unverified email address.
:param unicode email: the email address to add
:param Package packages: packages to optionally also verify ownership of
:param gratipay.models.package.Package packages: packages to optionally
also verify ownership of
:returns: ``None``
Expand Down Expand Up @@ -201,63 +202,107 @@ def start_package_claims(self, c, nonce, *packages):
)


def update_email(self, email):
"""Set the email address for the participant.
def set_primary_email(self, email, cursor=None):
"""Set the primary email address for the participant.
"""
if not getattr(self.get_email(email), 'verified', False):
raise EmailNotVerified()
username = self.username
with self.db.get_cursor() as c:
self.app.add_event( c
, 'participant'
, dict(id=self.id, action='set', values=dict(primary_email=email))
)
c.run("""
UPDATE participants
SET email_address=%(email)s
WHERE username=%(username)s
""", locals())
if cursor:
self._set_primary_email(email, cursor)
else:
with self.db.get_cursor() as cursor:
self._set_primary_email(email, cursor)
self.set_attributes(email_address=email)


def verify_email(self, email, nonce):
if '' in (email, nonce):
def _set_primary_email(self, email, cursor):
if not getattr(self.get_email(email, cursor), 'verified', False):
raise EmailNotVerified()
self.app.add_event( cursor
, 'participant'
, dict(id=self.id, action='set', values=dict(primary_email=email))
)
cursor.run("""
UPDATE participants
SET email_address=%(email)s
WHERE username=%(username)s
""", dict(email=email, username=self.username))


def finish_email_verification(self, email, nonce):
if '' in (email.strip(), nonce.strip()):
return VERIFICATION_MISSING
r = self.get_email(email)
if r is None:
return VERIFICATION_FAILED
if r.verified:
assert r.nonce is None # and therefore, order of conditions matters
return VERIFICATION_REDUNDANT
if not constant_time_compare(r.nonce, nonce):
return VERIFICATION_FAILED
if (utcnow() - r.verification_start) > EMAIL_HASH_TIMEOUT:
return VERIFICATION_EXPIRED
try:
self.db.run("""
UPDATE emails
SET verified=true, verification_end=now(), nonce=NULL
WHERE participant_id=%s
AND address=%s
AND verified IS NULL
""", (self.id, email))
except IntegrityError:
return VERIFICATION_STYMIED
with self.db.get_cursor() as cursor:
record = self.get_email(email, cursor)
if record is None:
return VERIFICATION_FAILED
packages = self.get_packages_claiming(cursor, nonce)
if record.verified and not packages:
assert record.nonce is None # and therefore, order of conditions matters
return VERIFICATION_REDUNDANT
if not constant_time_compare(record.nonce, nonce):
return VERIFICATION_FAILED
if (utcnow() - record.verification_start) > EMAIL_HASH_TIMEOUT:
return VERIFICATION_EXPIRED
try:
self.finish_package_claims(cursor, nonce, *packages)
self.save_email_address(cursor, email)
except IntegrityError:
return VERIFICATION_STYMIED
return VERIFICATION_SUCCEEDED


def get_packages_claiming(self, cursor, nonce):
"""Given a nonce, return :py:class:`~gratipay.models.package.Package`
objects associated with it.
"""
return cursor.all("""
SELECT p.*::packages
FROM packages p
JOIN claims c
ON p.id = c.package_id
WHERE c.nonce=%s
""", (nonce,))


def save_email_address(self, cursor, address):
"""Given an email address, modify the database.
"""
cursor.run("""
UPDATE emails
SET verified=true, verification_end=now(), nonce=NULL
WHERE participant_id=%s
AND address=%s
AND verified IS NULL
""", (self.id, address))
if not self.email_address:
self.update_email(email)
return VERIFICATION_SUCCEEDED
self.set_primary_email(address, cursor)


def finish_package_claims(self, cursor, nonce, *packages):
"""Create teams if needed and associate them with the packages.
"""
cursor.run('DELETE FROM claims WHERE nonce=%s', (nonce,))
package_ids = []
for package in packages:
package.get_or_create_linked_team(cursor, self)
package_ids.append(package.id)
self.app.add_event( cursor
, 'participant'
, dict( id=self.id
, action='finish-claim'
, values=dict(package_ids=package_ids)
)
)


def get_email(self, email):
def get_email(self, address, cursor=None):
"""Return a record for a single email address on file for this participant.
"""
return self.db.one("""
return (cursor or self.db).one("""
SELECT *
FROM emails
WHERE participant_id=%s
AND address=%s
""", (self.id, email))
""", (self.id, address))


def get_emails(self):
Expand Down
6 changes: 4 additions & 2 deletions gratipay/models/team/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .available import Available
from .closing import Closing
from .membership import Membership
from .package import Package
from .takes import Takes
from .tip_migration import TipMigration

Expand All @@ -36,7 +37,7 @@ def slugize(name):
return slug


class Team(Model, Available, Closing, Membership, Takes, TipMigration):
class Team(Model, Available, Closing, Membership, Package, Takes, TipMigration):
"""Represent a Gratipay team.
"""

Expand Down Expand Up @@ -98,9 +99,10 @@ def _from_thing(cls, thing, value):

@classmethod
def insert(cls, owner, **fields):
cursor = fields.pop('_cursor') if '_cursor' in fields else None
fields['slug_lower'] = fields['slug'].lower()
fields['owner'] = owner.username
return cls.db.one("""
return (cursor or cls.db).one("""
INSERT INTO teams
(slug, slug_lower, name, homepage,
Expand Down
23 changes: 23 additions & 0 deletions gratipay/models/team/package.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function, unicode_literals


class Package(object):
"""A :py:class:`~gratipay.models.team.Team` can be associated with :py:class:`Package`.
"""

@property
def package(self):
"""A computed attribute, the
:py:class:`~gratipay.models.package.Package` linked to this team if
there is one, otherwise ``None``. Makes a database call.
"""
return self.load_package(self.db)


def load_package(self, cursor):
"""Given a database cursor, return a
:py:class:`~gratipay.models.package.Package` if there is one linked to
this package, or ``None`` if not.
"""
return cursor.one('SELECT p.*::packages FROM packages p WHERE p.team_id=%s', (self.id,))
2 changes: 2 additions & 0 deletions sql/branch.sql
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ BEGIN;
, UNIQUE(nonce, package_id)
);

ALTER TABLE packages ADD COLUMN team_id bigint UNIQUE REFERENCES teams(id) ON DELETE RESTRICT;

END;
Loading

0 comments on commit 404107c

Please sign in to comment.