From 8372b033732b7223a749ba799fe27ab4b6e99108 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Tue, 18 Dec 2007 00:05:13 +0000 Subject: [PATCH] Began work on invite redeeming. --- controller/Expose.py | 1 + controller/Root.py | 17 ++++ controller/Users.py | 76 +++++++++++++++++ controller/test/Stub_database.py | 2 +- controller/test/Test_controller.py | 2 +- controller/test/Test_root.py | 6 ++ controller/test/Test_users.py | 128 ++++++++++++++++++++++++++++- model/Invite.py | 4 +- model/User.py | 9 ++ 9 files changed, 239 insertions(+), 6 deletions(-) diff --git a/controller/Expose.py b/controller/Expose.py index 66f7d6a..db1cab3 100644 --- a/controller/Expose.py +++ b/controller/Expose.py @@ -55,6 +55,7 @@ def expose( view = None, rss = None ): except Exception, error: original_error = error if hasattr( error, "to_dict" ): + if not view: raise error result = error.to_dict() else: import traceback diff --git a/controller/Root.py b/controller/Root.py index 7572a2d..c70b29f 100644 --- a/controller/Root.py +++ b/controller/Root.py @@ -88,6 +88,23 @@ class Root( object ): redirect = u"/users/redeem_reset/%s" % password_reset_id, ) + @expose() + def i( self, invite_id ): + """ + Redirect to the invite redemption URL, based on the given invite id. The sole purpose of this + method is to shorten invite redemption URLs sent by email so email clients don't wrap them. + """ + # if the value looks like an id, it's an invite id, so redirect + try: + validator = Valid_id() + invite_id = validator( invite_id ) + except ValueError: + raise cherrypy.NotFound + + return dict( + redirect = u"/users/redeem_invite/%s" % invite_id, + ) + @expose( view = Main_page ) @strongly_expire @grab_user_id diff --git a/controller/Users.py b/controller/Users.py index a9847fa..78f212c 100644 --- a/controller/Users.py +++ b/controller/Users.py @@ -738,6 +738,13 @@ class Users( object ): similar.owner = owner self.__database.save( similar, commit = False ) + # if the invite is already redeemed, then update the relevant entry in the user_notebook + # access table as well + if similar.redeemed_user_id is not None: + redeemed_user = self.__database.load( User, redeemed_user_id ) + if redeemed_user: + self.__database.execute( redeemed_user.sql_update_access( notebook_id, read_write, owner ) ) + # create an email message with a unique invitation link notebook_name = notebook.name.strip().replace( "\n", " " ).replace( "\r", " " ) message = Message.Message() @@ -811,3 +818,72 @@ class Users( object ): message = u"Notebook access for %s has been revoked." % invite.email_address, invites = invites, ) + + @expose( view = Main_page ) + @grab_user_id + @validate( + invite_id = Valid_id(), + user_id = Valid_id( none_okay = True ), + ) + def redeem_invite( self, invite_id, user_id = None ): + """ + Begin the process of redeeming a notebook invite. + + @type invite_id: unicode + @param invite_id: id of invite to redeem + @type user_id: unicode + @param user_id: id of current logged-in user (if any), determined by @grab_user_id + @rtype: + @return: + @raise Validation_error: one of the arguments is invalid + @raise Invite_error: an error occured when redeeming the invite + """ + invite = self.__database.load( Invite, invite_id ) + if not invite: + raise Invite_error( "That invite is unknown. Please make sure that you typed the address correctly." ) + + if user_id is not None: + # if the user is logged in but the invite is unredeemed, redeem it and redirect to the notebook + if invite.redeemed_user_id is None: + self.convert_invite_to_access( invite, user_id ) + return dict( redirect = u"/notebooks/%s" % invite.notebook_id ) + + # if the user is logged in and has already redeemed this invite, then just redirect to the notebook + if invite.redeemed_user_id == user_id: + return dict( redirect = u"/notebooks/%s" % invite.notebook_id ) + else: + raise Invite_error( u"That invite has already been used by someone else." ) + + if invite.redeemed_user_id: + raise Invite_error( u"That invite has already been used. If you were the one who used it, then simply login to your account." ) + + # TODO: give the user the option to sign up or login in order to redeem the invite + + def convert_invite_to_access( self, invite, user_id ): + """ + Grant the given user access to the notebook specified in the invite, and mark that invite as + redeemed. + + @type invite: model.Invite + @param invite: invite to convert to notebook access + @type user_id: unicode + @param user_id: id of current logged-in user (if any), determined by @grab_user_id + @raise Invite_error: an error occured when redeeming the invite + """ + user = self.__database.load( User, user_id ) + notebook = self.__database.load( Notebook, invite.notebook_id ) + if not user or not notebook: + raise Invite_error( "There was an error when redeeming your invite. Please contact %s." % self.__support_email ) + + # if the user doesn't already have access to this notebook, then grant access + if not self.__database.select_one( bool, user.sql_has_access( notebook.object_id ) ): + self.__database.execute( user.sql_save_notebook( notebook.object_id, invite.read_write, invite.owner ), commit = False ) + + # the same goes for the trash notebook + if not self.__database.select_one( bool, user.sql_has_access( notebook.trash_id ) ): + self.__database.execute( user.sql_save_notebook( notebook.trash_id, invite.read_write, invite.owner ), commit = False ) + + invite.redeemed_user_id = user_id + self.__database.save( invite, commit = False ) + + self.__database.commit() diff --git a/controller/test/Stub_database.py b/controller/test/Stub_database.py index 60ef9b5..05419f1 100644 --- a/controller/test/Stub_database.py +++ b/controller/test/Stub_database.py @@ -5,7 +5,7 @@ class Stub_database( object ): def __init__( self, connection = None ): # map of object id to list of saved objects (presumably in increasing order of revisions) self.objects = {} - self.user_notebook = {} # map of user_id to ( notebook_id, read_write ) + self.user_notebook = {} # map of user_id to ( notebook_id, read_write, owner ) self.last_saved_obj = None self.__next_id = 0 diff --git a/controller/test/Test_controller.py b/controller/test/Test_controller.py index 4ad82c4..dfee48d 100644 --- a/controller/test/Test_controller.py +++ b/controller/test/Test_controller.py @@ -221,7 +221,7 @@ class Test_controller( object ): for ( object_id, obj_list ) in database.objects.items(): obj = obj_list[ -1 ] if isinstance( obj, Invite ) and obj.notebook_id == self.notebook_id and \ - obj.email_address == self.email_address and obj.redeemed_user_id is None and \ + obj.email_address == self.email_address and \ obj.object_id != self.object_id: invites.append( obj ) diff --git a/controller/test/Test_root.py b/controller/test/Test_root.py index 8808754..22e3694 100644 --- a/controller/test/Test_root.py +++ b/controller/test/Test_root.py @@ -213,3 +213,9 @@ class Test_root( Test_controller ): result = self.http_get( "/r/%s" % redeem_reset_id ) assert result[ u"redirect" ] == u"/users/redeem_reset/%s" % redeem_reset_id + + def test_redeem_invite( self ): + invite_id = u"foobarbaz" + result = self.http_get( "/i/%s" % invite_id ) + + assert result[ u"redirect" ] == u"/users/redeem_invite/%s" % invite_id diff --git a/controller/test/Test_users.py b/controller/test/Test_users.py index 3387137..3a6d703 100644 --- a/controller/test/Test_users.py +++ b/controller/test/Test_users.py @@ -4,14 +4,14 @@ import smtplib from pytz import utc from nose.tools import raises from datetime import datetime, timedelta -from nose.tools import raises from Test_controller import Test_controller from Stub_smtp import Stub_smtp from model.User import User from model.Notebook import Notebook from model.Note import Note from model.Password_reset import Password_reset -from controller.Users import Access_error +from model.Invite import Invite +from controller.Users import Invite_error class Test_users( Test_controller ): @@ -1001,6 +1001,13 @@ class Test_users( Test_controller ): invite_id1 = matches.group( 2 ) assert invite_id1 + # update the user_notebook table accordingly. this normally happens when an invite is redeemed + self.database.execute( self.user.sql_save_notebook( + self.notebooks[ 0 ].object_id, + read_write = False, + owner = False, + ) ) + # then send a similar invite to the same email address with read_write and owner set to True result = self.http_post( "/users/send_invites", dict( notebook_id = self.notebooks[ 0 ].object_id, @@ -1037,6 +1044,14 @@ class Test_users( Test_controller ): assert invite2.read_write is True assert invite2.owner is True + # assert that the user_notebook table has also been updated accordingly + access = self.database.select_one( bool, self.user.sql_has_access( + self.notebooks[ 0 ].object_id, + read_write = True, + owner = True, + ) ) + assert access is True + def test_send_invites_with_generic_from_address( self ): Stub_smtp.reset() smtplib.SMTP = Stub_smtp @@ -1483,6 +1498,115 @@ class Test_users( Test_controller ): assert result[ u"error" ] assert "access" in result[ u"error" ] + def test_convert_invite_to_access( self ): + # trick send_invites() into using a fake SMTP server + Stub_smtp.reset() + smtplib.SMTP = Stub_smtp + self.login() + + self.user.rate_plan = 1 + self.database.save( self.user ) + + email_addresses_list = [ u"foo@example.com" ] + email_addresses = email_addresses_list[ 0 ] + + self.http_post( "/users/send_invites", dict( + notebook_id = self.notebooks[ 0 ].object_id, + email_addresses = email_addresses, + access = u"viewer", + invite_button = u"send invites", + ), session_id = self.session_id ) + + matches = self.INVITE_LINK_PATTERN.search( smtplib.SMTP.message ) + invite_id = matches.group( 2 ) + + invite = self.database.load( Invite, invite_id ) + cherrypy.root.users.convert_invite_to_access( invite, self.user.object_id ) + + access = self.database.select_one( bool, self.user.sql_has_access( + invite.notebook_id, + invite.read_write, + invite.owner, + ) ) + assert access is True + + notebook = self.database.load( Notebook, invite.notebook_id ) + access = self.database.select_one( bool, self.user.sql_has_access( + notebook.trash_id, + invite.read_write, + invite.owner, + ) ) + assert access is True + + assert invite.redeemed_user_id == self.user.object_id + + def test_convert_invite_to_access_twice( self ): + Stub_smtp.reset() + smtplib.SMTP = Stub_smtp + self.login() + + self.user.rate_plan = 1 + self.database.save( self.user ) + + email_addresses_list = [ u"foo@example.com" ] + email_addresses = email_addresses_list[ 0 ] + + self.http_post( "/users/send_invites", dict( + notebook_id = self.notebooks[ 0 ].object_id, + email_addresses = email_addresses, + access = u"viewer", + invite_button = u"send invites", + ), session_id = self.session_id ) + + matches = self.INVITE_LINK_PATTERN.search( smtplib.SMTP.message ) + invite_id = matches.group( 2 ) + + invite = self.database.load( Invite, invite_id ) + cherrypy.root.users.convert_invite_to_access( invite, self.user.object_id ) + cherrypy.root.users.convert_invite_to_access( invite, self.user.object_id ) + + access = self.database.select_one( bool, self.user.sql_has_access( + invite.notebook_id, + invite.read_write, + invite.owner, + ) ) + assert access is True + + notebook = self.database.load( Notebook, invite.notebook_id ) + access = self.database.select_one( bool, self.user.sql_has_access( + notebook.trash_id, + invite.read_write, + invite.owner, + ) ) + assert access is True + + assert invite.redeemed_user_id == self.user.object_id + + @raises( Invite_error ) + def test_convert_invite_with_unknown_user( self ): + Stub_smtp.reset() + smtplib.SMTP = Stub_smtp + self.login() + + self.user.rate_plan = 1 + self.database.save( self.user ) + + email_addresses_list = [ u"foo@example.com" ] + email_addresses = email_addresses_list[ 0 ] + + self.http_post( "/users/send_invites", dict( + notebook_id = self.notebooks[ 0 ].object_id, + email_addresses = email_addresses, + access = u"viewer", + invite_button = u"send invites", + ), session_id = self.session_id ) + + matches = self.INVITE_LINK_PATTERN.search( smtplib.SMTP.message ) + invite_id = matches.group( 2 ) + + invite = self.database.load( Invite, invite_id ) + cherrypy.root.users.convert_invite_to_access( invite, u"unknown_user_id" ) + def login( self ): result = self.http_post( "/users/login", dict( username = self.username, diff --git a/model/Invite.py b/model/Invite.py index 8563492..b7cf227 100644 --- a/model/Invite.py +++ b/model/Invite.py @@ -91,9 +91,9 @@ class Invite( Persistent ): quote( self.__redeemed_user_id ), quote( self.object_id ) ) def sql_load_similar( self ): - # select unredeemed invites with the same notebook_id, and email_address as this invite + # select invites with the same notebook_id, and email_address as this invite return "select id, revision, from_user_id, notebook_id, email_address, read_write, owner, redeemed_user_id from invite " + \ - "where notebook_id = %s and email_address = %s and id != %s and redeemed_user_id is null;" % \ + "where notebook_id = %s and email_address = %s and id != %s;" % \ ( quote( self.__notebook_id ), quote( self.__email_address ), quote( self.object_id ) ) @staticmethod diff --git a/model/User.py b/model/User.py index d0a1c53..ced036f 100644 --- a/model/User.py +++ b/model/User.py @@ -185,6 +185,15 @@ class User( Persistent ): "select user_id from user_notebook where user_id = %s and notebook_id = %s;" % \ ( quote( self.object_id ), quote( notebook_id ) ) + def sql_update_access( self, notebook_id, read_write = False, owner = False ): + """ + Return a SQL string to update the user's notebook access to the given read_write and owner level. + """ + return \ + "update user_notebook set read_write = %s, owner = %s where user_id = %s and notebook_id = %s;" % \ + ( quote( read_write and 't' or 'f' ), quote( owner and 't' or 'f' ), quote( self.object_id ), + quote( notebook_id ) ) + def sql_calculate_storage( self ): """ Return a SQL string to calculate the total bytes of storage usage by this user. Note that this