This repository has been archived by the owner on Feb 8, 2018. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 308
Receive package claim link-back #4397
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
b4267f3
Implement package claiming
chadwhitacre c7f08a8
Clean up package.team API a bit
chadwhitacre d041eb7
Update team.package API to match
chadwhitacre eaa8699
Failing test for concurrency-safety in claiming
chadwhitacre 1d42094
Make finish_email_verification concurrency-safe
chadwhitacre File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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`` | ||
|
||
|
@@ -201,63 +202,111 @@ 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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the place where I'm talking about the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, this is a bit of a goofy construction. liberapay/postgres.py#40 would help. |
||
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, and_lock=True) | ||
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.ensure_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, and_lock=False): | ||
"""Return a record for a single email address on file for this participant. | ||
|
||
:param unicode address: the email address for which to get a record | ||
:param Cursor cursor: a database cursor; if ``None``, we'll use ``self.db`` | ||
:param and_lock: if True, we will acquire a write-lock on the email record before returning | ||
:returns: a database record (a named tuple) | ||
|
||
""" | ||
return self.db.one(""" | ||
SELECT * | ||
FROM emails | ||
WHERE participant_id=%s | ||
AND address=%s | ||
""", (self.id, email)) | ||
sql = 'SELECT * FROM emails WHERE participant_id=%s AND address=%s' | ||
if and_lock: | ||
sql += ' FOR UPDATE' | ||
return (cursor or self.db).one(sql, (self.id, address)) | ||
|
||
|
||
def get_emails(self): | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
|
||
|
||
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): | ||
return cursor.one( 'SELECT p.*::packages FROM packages p WHERE p.id=' | ||
'(SELECT package_id FROM teams_to_packages tp WHERE tp.team_id=%s)' | ||
, (self.id,) | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there an implicit transaction here? What if this method is called concurrently? Seems like you could wind up with multiple entries with different UUIDs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
" ... called concurrently with the same input."
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If
cursors
is actuallyself.db
then yes, there is an implicit transaction under here. However, the purpose of exposing thecursor
parameter is precisely to allow passing in a proper cursor, so that an entire call chain can operate within one transaction to preserve integrity. In some methodscursor
is optional (e.g.), butcursor
is required and front-loaded here because integrity is an issue here.The transaction of which
ensure_teams
is a part begins infinish_email_verification
. Iffinish_email_verification
is called concurrently with the same input, then ... I don't know. I've been working most of the day on trying to sort this out. What I have so far is in 8da4f9c.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here's the output I'm seeing:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That looks to me like there's something hidden from view that is synchronizing the threads. Even though I start them in parallel, they appear to run in sequence. Hmm ... 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah! I think it's because the ... well, wait ...
I was gonna say because the calls synchronize on the
cursor.run('DELETE ...')
call (why that one instead of theSELECT
s? Is psycopg2/Postgres being fancy?), so that the second cursor sees a certain state following the lock release, ... but wouldn't a second insert onteams_to_packages
still fail?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here we go: team is not
None
the second time around.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Gosh. Is there a different possible behavior for each cursor call? 😳
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
https://www.postgresql.org/docs/9.3/static/explicit-locking.html
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Woo-hoo! 💃 ✊