diff --git a/config/Common.py b/config/Common.py index b6a6067..7113906 100644 --- a/config/Common.py +++ b/config/Common.py @@ -41,7 +41,7 @@ settings = { }, { "name": "premium", - "storage_quota_bytes": 1000 * MEGABYTE, + "storage_quota_bytes": 2000 * MEGABYTE, "notebook_collaboration": True, }, ], diff --git a/controller/Root.py b/controller/Root.py index c70b29f..6373361 100644 --- a/controller/Root.py +++ b/controller/Root.py @@ -45,10 +45,18 @@ class Root( object ): @expose( Main_page ) @validate( note_title = unicode, + invite_id = Valid_id( none_okay = True ), ) - def default( self, note_title ): + def default( self, note_title, invite_id = None ): """ Convenience method for accessing a note in the main notebook by name rather than by note id. + + @type note_title: unicode + @param note_title: title of the note to return + @type invite_id: unicode + @param invite_id: id of the invite used to get to this note (optional) + @rtype: unicode + @return: rendered HTML page """ # if the user is logged in and not using https, and they request the sign up or login note, then # redirect to the https version of the page (if available) @@ -56,7 +64,10 @@ class Root( object ): https_proxy_ip = self.__settings[ u"global" ].get( u"luminotes.https_proxy_ip" ) if note_title in ( u"sign_up", u"login" ) and https_url and cherrypy.request.remote_addr != https_proxy_ip: - return dict( redirect = u"%s/%s" % ( https_url, note_title ) ) + if invite_id: + return dict( redirect = u"%s/%s?invite_id=%s" % ( https_url, note_title, invite_id ) ) + else: + return dict( redirect = u"%s/%s" % ( https_url, note_title ) ) result = self.__users.current( user_id = None ) first_notebook = result[ u"notebooks" ][ 0 ] @@ -68,6 +79,8 @@ class Root( object ): raise cherrypy.NotFound result.update( self.__notebooks.contents( first_notebook.object_id, user_id = user_id, note_id = note.object_id ) ) + if invite_id: + result[ "invite_id" ] = invite_id return result diff --git a/controller/Users.py b/controller/Users.py index 78f212c..f2cba66 100644 --- a/controller/Users.py +++ b/controller/Users.py @@ -14,6 +14,7 @@ from Expire import strongly_expire from view.Json import Json from view.Main_page import Main_page from view.Redeem_reset_note import Redeem_reset_note +from view.Redeem_invite_note import Redeem_invite_note USERNAME_PATTERN = re.compile( "^[a-zA-Z0-9]+$" ) @@ -180,8 +181,9 @@ class Users( object ): password_repeat = Valid_string( min = 1, max = 30 ), email_address = ( Valid_string( min = 0, max = 60 ) ), signup_button = unicode, + invite_id = Valid_id( none_okay = True ), ) - def signup( self, username, password, password_repeat, email_address, signup_button ): + def signup( self, username, password, password_repeat, email_address, signup_button, invite_id = None ): """ Create a new User based on the given information. Start that user with their own Notebook and a "welcome to your wiki" Note. For convenience, login the newly created user as well. @@ -196,6 +198,8 @@ class Users( object ): @param email_address: user's email address @type signup_button: unicode @param signup_button: ignored + @type invite_id: unicode + @param invite_id: id of invite to redeem upon signup (optional) @rtype: json dict @return: { 'redirect': url, 'authenticated': userdict } @raise Signup_error: passwords don't match or the username is unavailable @@ -240,7 +244,17 @@ class Users( object ): self.__database.execute( user.sql_save_notebook( trash_id, read_write = True, owner = True ), commit = False ) self.__database.commit() - redirect = u"/notebooks/%s" % notebook.object_id + # if there's an invite_id, then redeem that invite and redirect to the invite's notebook + if invite_id: + invite = self.__database.load( Invite, invite_id ) + if not invite: + raise Signup_error( u"The invite is unknown." ) + + self.convert_invite_to_access( invite, user_id ) + redirect = u"/notebooks/%s" % invite.notebook_id + # otherwise, just redirect to the newly created notebook + else: + redirect = u"/notebooks/%s" % notebook.object_id return dict( redirect = redirect, @@ -316,8 +330,9 @@ class Users( object ): username = ( Valid_string( min = 1, max = 30 ), valid_username ), password = Valid_string( min = 1, max = 30 ), login_button = unicode, + invite_id = Valid_id( none_okay = True ), ) - def login( self, username, password, login_button ): + def login( self, username, password, login_button, invite_id = None ): """ Attempt to authenticate the user. If successful, associate the given user with the current session. @@ -326,6 +341,8 @@ class Users( object ): @param username: username to login @type password: unicode @param password: the user's password + @type invite_id: unicode + @param invite_id: id of invite to redeem upon login (optional) @rtype: json dict @return: { 'redirect': url, 'authenticated': userdict } @raise Authentication_error: invalid username or password @@ -338,8 +355,16 @@ class Users( object ): first_notebook = self.__database.select_one( Notebook, user.sql_load_notebooks( parents_only = True, undeleted_only = True ) ) - # redirect to the user's first notebook (if any) - if first_notebook: + # if there's an invite_id, then redeem that invite and redirect to the invite's notebook + if invite_id: + invite = self.__database.load( Invite, invite_id ) + if not invite: + raise Authentication_error( u"The invite is unknown." ) + + self.convert_invite_to_access( invite, user.object_id ) + redirect = u"/notebooks/%s" % invite.notebook_id + # otherwise, just redirect to the user's first notebook (if any) + elif first_notebook: redirect = u"/notebooks/%s" % first_notebook.object_id else: redirect = u"/" @@ -741,7 +766,7 @@ class Users( object ): # 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 ) + redeemed_user = self.__database.load( User, similar.redeemed_user_id ) if redeemed_user: self.__database.execute( redeemed_user.sql_update_access( notebook_id, read_write, owner ) ) @@ -833,8 +858,8 @@ class Users( object ): @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: + @rtype: unicode + @return: rendered HTML page @raise Validation_error: one of the arguments is invalid @raise Invite_error: an error occured when redeeming the invite """ @@ -857,7 +882,32 @@ class Users( object ): 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 + notebook = self.__database.load( Notebook, invite.notebook_id ) + if not notebook: + raise Invite_error( "That notebook you've been invited to is unknown." ) + + anonymous = self.__database.select_one( User, User.sql_load_by_username( u"anonymous" ) ) + if anonymous: + main_notebook = self.__database.select_one( Notebook, anonymous.sql_load_notebooks( undeleted_only = True ) ) + invite_notebook = self.__database.load( Notebook, invite.notebook_id ) + + if not anonymous or not main_notebook or not invite_notebook: + raise Password_reset_error( "There was an error when redeeming your invite. Please contact %s." % self.__support_email ) + + # give the user the option to sign up or login in order to redeem the invite + result = self.current( anonymous.object_id ) + result[ "notebook" ] = main_notebook + result[ "startup_notes" ] = self.__database.select_many( Note, main_notebook.sql_load_startup_notes() ) + result[ "total_notes_count" ] = self.__database.select_one( Note, main_notebook.sql_count_notes() ) + result[ "note_read_write" ] = False + result[ "notes" ] = [ Note.create( + object_id = u"redeem_invite", + contents = unicode( Redeem_invite_note( invite, invite_notebook ) ), + notebook_id = main_notebook.object_id, + ) ] + result[ "invites" ] = [] + + return result def convert_invite_to_access( self, invite, user_id ): """ diff --git a/controller/test/Stub_database.py b/controller/test/Stub_database.py index 05419f1..aadab6f 100644 --- a/controller/test/Stub_database.py +++ b/controller/test/Stub_database.py @@ -1,4 +1,5 @@ from copy import copy +from model.User import User class Stub_database( object ): @@ -7,10 +8,13 @@ class Stub_database( object ): self.objects = {} self.user_notebook = {} # map of user_id to ( notebook_id, read_write, owner ) self.last_saved_obj = None + self.last_saved_user = None self.__next_id = 0 def save( self, obj, commit = False ): self.last_saved_obj = obj + if isinstance( obj, User ): + self.last_saved_user = obj if obj.object_id in self.objects: self.objects[ obj.object_id ].append( copy( obj ) ) else: diff --git a/controller/test/Test_root.py b/controller/test/Test_root.py index 22e3694..7ae0458 100644 --- a/controller/test/Test_root.py +++ b/controller/test/Test_root.py @@ -103,6 +103,18 @@ class Test_root( Test_controller ): assert result[ u"notes" ][ 0 ].object_id == self.anon_note.object_id assert result[ u"notebook" ].object_id == self.anon_notebook.object_id + def test_default_with_invite_id( self ): + result = self.http_get( + "/my_note?invite_id=whee", + ) + + assert result + assert result[ u"notes" ] + assert len( result[ u"notes" ] ) == 1 + assert result[ u"notes" ][ 0 ].object_id == self.anon_note.object_id + assert result[ u"notebook" ].object_id == self.anon_notebook.object_id + assert result[ u"invite_id" ] == u"whee" + def test_default_with_unknown_note( self ): result = self.http_get( "/unknown_note", diff --git a/controller/test/Test_users.py b/controller/test/Test_users.py index 3a6d703..aaa3f9f 100644 --- a/controller/test/Test_users.py +++ b/controller/test/Test_users.py @@ -44,6 +44,9 @@ class Test_users( Test_controller ): trash_id1 = self.database.next_id( Notebook ) trash_id2 = self.database.next_id( Notebook ) + self.database.save( Notebook.create( trash_id1, u"trash" ) ) + self.database.save( Notebook.create( trash_id2, u"trash" ) ) + self.notebooks = [ Notebook.create( notebook_id1, u"my notebook", trash_id = trash_id1 ), Notebook.create( notebook_id2, u"my other notebook", trash_id = trash_id2 ), @@ -158,6 +161,83 @@ class Test_users( Test_controller ): assert rate_plan[ u"name" ] == u"super" assert rate_plan[ u"storage_quota_bytes" ] == 1337 + def test_current_after_signup_with_invite_id( 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 ] + + result = 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 ) + + result = self.http_post( "/users/signup", dict( + username = self.new_username, + password = self.new_password, + password_repeat = self.new_password, + email_address = self.new_email_address, + signup_button = u"sign up", + invite_id = invite_id, + ) ) + + invite_notebook_id = result[ u"redirect" ].split( u"/notebooks/" )[ -1 ] + assert invite_notebook_id == self.notebooks[ 0 ].object_id + + user = self.database.last_saved_user + assert isinstance( user, User ) + result = cherrypy.root.users.current( user.object_id ) + + assert result[ u"user" ].object_id == user.object_id + assert result[ u"user" ].username == self.new_username + assert result[ u"user" ].email_address == self.new_email_address + + assert cherrypy.root.users.check_access( user.object_id, self.notebooks[ 0 ].object_id ) + assert cherrypy.root.users.check_access( user.object_id, self.notebooks[ 0 ].trash_id ) + + # the notebook that the user was invited to should be in the list of returned notebooks + notebooks = dict( [ ( notebook.object_id, notebook ) for notebook in result[ u"notebooks" ] ] ) + + notebook = notebooks.get( invite_notebook_id ) + assert notebook + assert notebook.revision + assert notebook.name == self.notebooks[ 0 ].name + assert notebook.trash_id + assert notebook.read_write == False + assert notebook.owner == False + + notebook = notebooks.get( self.notebooks[ 0 ].trash_id ) + assert notebook.revision + assert notebook.name == u"trash" + assert notebook.trash_id == None + assert notebook.read_write == False + assert notebook.owner == False + + notebook = notebooks.get( self.anon_notebook.object_id ) + assert notebook.revision == self.anon_notebook.revision + assert notebook.name == self.anon_notebook.name + assert notebook.trash_id == None + assert notebook.read_write == False + assert notebook.owner == False + + assert result.get( u"login_url" ) is None + assert result[ u"logout_url" ] == self.settings[ u"global" ][ u"luminotes.https_url" ] + u"/" + + rate_plan = result[ u"rate_plan" ] + assert rate_plan[ u"name" ] == u"super" + assert rate_plan[ u"storage_quota_bytes" ] == 1337 + def test_signup_with_different_passwords( self ): result = self.http_post( "/users/signup", dict( username = self.new_username, @@ -329,6 +409,41 @@ class Test_users( Test_controller ): assert rate_plan[ u"name" ] == u"super" assert rate_plan[ u"storage_quota_bytes" ] == 1337 + def test_current_after_login_with_invite_id( 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 ] + + result = 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 ) + + result = self.http_post( "/users/login", dict( + username = self.username2, + password = self.password2, + invite_id = invite_id, + login_button = u"login", + ) ) + + invite_notebook_id = result[ u"redirect" ].split( u"/notebooks/" )[ -1 ] + assert invite_notebook_id == self.notebooks[ 0 ].object_id + + assert cherrypy.root.users.check_access( self.user2.object_id, self.notebooks[ 0 ].object_id ) + assert cherrypy.root.users.check_access( self.user2.object_id, self.notebooks[ 0 ].trash_id ) + def test_update_storage( self ): previous_revision = self.user.revision @@ -1614,3 +1729,11 @@ class Test_users( Test_controller ): login_button = u"login", ) ) self.session_id = result[ u"session_id" ] + + def login2( self ): + result = self.http_post( "/users/login", dict( + username = self.username2, + password = self.password2, + login_button = u"login", + ) ) + self.session_id = result[ u"session_id" ] diff --git a/static/html/upgrade.html b/static/html/upgrade.html index a7d2cc1..ca3676a 100644 --- a/static/html/upgrade.html +++ b/static/html/upgrade.html @@ -6,13 +6,13 @@ Free Basic
$5/month Standard
$9/month - Premium
$14/month + Premium
$19/month included storage space 30 MB 250 MB 500 MB - 1000 MB + 2000 MB unlimited wiki notebooks diff --git a/static/js/Wiki.js b/static/js/Wiki.js index 6702003..6e3ef70 100644 --- a/static/js/Wiki.js +++ b/static/js/Wiki.js @@ -13,6 +13,7 @@ function Wiki( invoker ) { this.rate_plan = evalJSON( getElement( "rate_plan" ).value ); this.storage_usage_high = false; this.invites = evalJSON( getElement( "invites" ).value ); + this.invite_id = getElement( "invite_id" ).value ; var total_notes_count_node = getElement( "total_notes_count" ); if ( total_notes_count_node ) @@ -642,7 +643,11 @@ Wiki.prototype.create_editor = function ( id, note_text, deleted_from_id, revisi connect( editor, "hide_clicked", function ( event ) { self.hide_editor( event, editor ) } ); connect( editor, "invites_updated", function ( invites ) { self.invites = invites; self.share_notebook(); } ); connect( editor, "submit_form", function ( url, form, callback ) { - self.invoker.invoke( url, "POST", null, callback, form ); + var args = {} + if ( url == "/users/signup" || url == "/users/login" ) + args[ "invite_id" ] = self.invite_id; + + self.invoker.invoke( url, "POST", args, callback, form ); } ); connect( editor, "revoke_invite", function ( invite_id, callback ) { self.invoker.invoke( "/users/revoke_invite", "POST", { @@ -1346,23 +1351,20 @@ Wiki.prototype.display_invites = function ( invite_area ) { "title": "revoke this person's notebook access" } ); + var add_invite_to = null; if ( invite.owner ) { - appendChildNodes( - owners, createDOM( "div", { "class": "invite" }, - invite.email_address, " ", revoke_button ) - ); + add_invite_to = owners; } else { if ( invite.read_write ) - appendChildNodes( - collaborators, createDOM( "div", { "class": "invite" }, - invite.email_address, " ", revoke_button ) - ); + add_invite_to = collaborators; else - appendChildNodes( - viewers, createDOM( "div", { "class": "invite" }, - invite.email_address, " ", revoke_button ) - ); + add_invite_to = viewers; } + + appendChildNodes( + add_invite_to, createDOM( "div", { "class": "invite" }, + invite.email_address, " ", revoke_button ) + ); } var div = createDOM( "div" ); diff --git a/view/Link_area.py b/view/Link_area.py index 1a8c17e..33bdcc6 100644 --- a/view/Link_area.py +++ b/view/Link_area.py @@ -4,7 +4,11 @@ from Rounded_div import Rounded_div class Link_area( Div ): def __init__( self, notebooks, notebook, total_notes_count, parent_id, notebook_path, user ): - linked_notebooks = [ nb for nb in notebooks if nb.read_write and nb.name not in ( u"trash" ) and nb.deleted is False ] + linked_notebooks = [ nb for nb in notebooks if + ( nb.read_write or not nb.name.startswith( u"Luminotes" ) ) and + nb.name not in ( u"trash" ) and + nb.deleted is False + ] Div.__init__( self, diff --git a/view/Main_page.py b/view/Main_page.py index 559047b..a248ad4 100644 --- a/view/Main_page.py +++ b/view/Main_page.py @@ -30,6 +30,7 @@ class Main_page( Page ): rename = False, deleted_id = None, invites = None, + invite_id = None, ): startup_note_ids = [ startup_note.object_id for startup_note in startup_notes ] @@ -100,6 +101,7 @@ class Main_page( Page ): Input( type = u"hidden", name = u"rename", id = u"rename", value = json( rename ) ), Input( type = u"hidden", name = u"deleted_id", id = u"deleted_id", value = deleted_id ), Input( type = u"hidden", name = u"invites", id = u"invites", value = json( invites ) ), + Input( type = u"hidden", name = u"invite_id", id = u"invite_id", value = invite_id ), Div( id = u"status_area", ), diff --git a/view/Redeem_invite_note.py b/view/Redeem_invite_note.py new file mode 100644 index 0000000..104e886 --- /dev/null +++ b/view/Redeem_invite_note.py @@ -0,0 +1,24 @@ +from Tags import Span, H3, P, A + + +class Redeem_invite_note( Span ): + def __init__( self, invite, notebook ): + title = None + + Span.__init__( + self, + H3( notebook.name ), + P( + u"You are just seconds away from viewing \"%s\"." % notebook.name, + ), + P( + u"If you already have a Luminotes account, then simply ", + A( u"login", href = u"/login?invite_id=%s" % invite.object_id, target = "_top" ), + u" to your account." + ), + P( + u"Otherwise, please ", + A( u"sign up", href = u"/sign_up?invite_id=%s" % invite.object_id, target = "_top" ), + u" for a free account." + ), + )