* Users.signup(), Users.login(), and Root.default() now support optional invite_id parameter.
* Modified Wiki.js to include invite_id parameter when necessary. * Increased storage quota (and price) for premium rate plan. * Added a note displayed when redeeming an invite, with links to signup and login.
This commit is contained in:
parent
8372b03373
commit
f00809955c
|
@ -41,7 +41,7 @@ settings = {
|
|||
},
|
||||
{
|
||||
"name": "premium",
|
||||
"storage_quota_bytes": 1000 * MEGABYTE,
|
||||
"storage_quota_bytes": 2000 * MEGABYTE,
|
||||
"notebook_collaboration": True,
|
||||
},
|
||||
],
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 <a href=\"/login\">login</a> 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 ):
|
||||
"""
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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" ]
|
||||
|
|
|
@ -6,13 +6,13 @@
|
|||
<th class="plan_name">Free</th>
|
||||
<th class="plan_name">Basic<br /><span class="price_text">$5<span class="month_text">/month</span></span></th>
|
||||
<th class="plan_name">Standard<br /><span class="price_text">$9<span class="month_text">/month</span></span></th>
|
||||
<th class="plan_name">Premium<br /><span class="price_text">$14<span class="month_text">/month</span></span></th>
|
||||
<th class="plan_name">Premium<br /><span class="price_text">$19<span class="month_text">/month</span></span></th>
|
||||
<tr>
|
||||
<td class="feature_name">included storage space</td>
|
||||
<td>30 MB</td>
|
||||
<td>250 MB</td>
|
||||
<td>500 MB</td>
|
||||
<td>1000 MB</td>
|
||||
<td>2000 MB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="feature_name">unlimited wiki notebooks</td>
|
||||
|
|
|
@ -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" );
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
),
|
||||
|
|
|
@ -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."
|
||||
),
|
||||
)
|
Reference in New Issue