diff --git a/NEWS b/NEWS index bf903d8..fee766d 100644 --- a/NEWS +++ b/NEWS @@ -1,5 +1,13 @@ -1.5.0 beta 2: +1.5.0: + * Fixed a Luminotes Desktop Internet Explorer bug in which note links within + the "download as html" document pointed to notes in the local Luminotes + installation instead of notes within the stand-alone document. + * Fixed a bug in which Luminotes Desktop file attachment did not always work + due to incorrect upload progress reporting. + * In the revision changes pulldown, no longer showing "by desktopuser" in + Luminotes Desktop. * Added a Luminotes Desktop download page. + * Added code for supporting product download access. 1.5.0 beta 1: August 27, 2008 * Completed the Luminotes Desktop Windows installer. diff --git a/config/Common.py b/config/Common.py index e6ec627..5530415 100644 --- a/config/Common.py +++ b/config/Common.py @@ -107,12 +107,26 @@ settings = { """, }, ], + "luminotes.download_products": [ + { + "name": "Luminotes Desktop", + "designed_for": "individuals", + "storage_quota_bytes": None, + "included_users": 1, + "notebook_sharing": False, + "notebook_collaboration": False, + "user_admin": False, + "fee": "20.00", + "item_number": "5000", + "filename": "luminotes.exe", + "button": + """ + """, + }, + ], "luminotes.unsubscribe_button": """ """, - "luminotes.download_button": - """ - """, }, "/files/download": { "stream_response": True, diff --git a/controller/Files.py b/controller/Files.py index e48416d..5737747 100644 --- a/controller/Files.py +++ b/controller/Files.py @@ -18,6 +18,7 @@ from Users import grab_user_id, Access_error from Expire import strongly_expire from model.File import File from model.User import User +from model.Download_access import Download_access from view.Upload_page import Upload_page from view.Blank_page import Blank_page from view.Json import Json @@ -249,7 +250,7 @@ class Files( object ): """ Controller for dealing with uploaded files, corresponding to the "/files" URL. """ - def __init__( self, database, users ): + def __init__( self, database, users, download_products ): """ Create a new Files object. @@ -257,11 +258,14 @@ class Files( object ): @param database: database that file metadata is stored in @type users: controller.Users @param users: controller for all users + @type download_products: [ { "name": unicode, ... } ] + @param download_products: list of configured downloadable products @rtype: Files @return: newly constructed Files """ self.__database = database self.__users = users + self.__download_products = download_products @expose() @end_transaction @@ -331,6 +335,68 @@ class Files( object ): return stream() + @expose() + @end_transaction + @validate( + access_id = Valid_id(), + item_number = Valid_int(), + ) + def download_product( self, access_id, item_number ): + """ + Return the contents of downloadable product file. + + @type access_id: unicode + @param access_id: id of download access object that grants access to the file + @type item_number: int or int as unicode + @param item_number: number of the downloadable product + @rtype: generator + @return: file data + @raise Access_error: the access_id is unknown, doesn't grant access to the file, or the + item_number is unknown + """ + # release the session lock before beginning to stream the download. otherwise, if the + # download is cancelled before it's done, the lock won't be released + try: + cherrypy.session.release_lock() + except ( KeyError, OSError ): + pass + + # find the product corresponding to the given item_number + products = [ + product for product in self.__download_products + if unicode( item_number ) == product.get( u"item_number" ) + ] + if len( products ) == 0: + raise Access_error() + + product = products[ 0 ] + + # load the download_access object corresponding to the given id + download_access = self.__database.load( Download_access, access_id ) + if download_access is None: + raise Access_error() + + public_filename = product[ u"filename" ].encode( "utf8" ) + local_filename = u"products/%s" % product[ u"filename" ] + + if not os.path.exists( local_filename ): + raise Access_error() + + cherrypy.response.headerMap[ u"Content-Type" ] = u"application/octet-stream" + cherrypy.response.headerMap[ u"Content-Disposition" ] = 'attachment; filename="%s"' % public_filename + cherrypy.response.headerMap[ u"Content-Length" ] = os.path.getsize( local_filename ) + + def stream(): + CHUNK_SIZE = 8192 + local_file = file( local_filename, "rb" ) + + while True: + data = local_file.read( CHUNK_SIZE ) + if len( data ) == 0: break + yield data + + return stream() + @expose( view = File_preview_page ) @end_transaction @grab_user_id diff --git a/controller/Root.py b/controller/Root.py index 2235e19..a3dc8f9 100644 --- a/controller/Root.py +++ b/controller/Root.py @@ -49,9 +49,14 @@ class Root( object ): settings[ u"global" ].get( u"luminotes.support_email", u"" ), settings[ u"global" ].get( u"luminotes.payment_email", u"" ), settings[ u"global" ].get( u"luminotes.rate_plans", [] ), + settings[ u"global" ].get( u"luminotes.download_products", [] ), ) self.__groups = Groups( database, self.__users ) - self.__files = Files( database, self.__users ) + self.__files = Files( + database, + self.__users, + settings[ u"global" ].get( u"luminotes.download_products", [] ), + ) self.__notebooks = Notebooks( database, self.__users, self.__files, settings[ u"global" ].get( u"luminotes.https_url", u"" ) ) self.__forums = Forums( database, self.__users ) self.__suppress_exceptions = suppress_exceptions # used for unit tests @@ -155,6 +160,24 @@ class Root( object ): redirect = u"/users/redeem_invite/%s" % invite_id, ) + @expose() + def d( self, download_access_id ): + """ + Redirect to the product download thanks URL, based on the given download access id. The sole + purpose of this method is to shorten product download URLs sent by email so email clients don't + wrap them. + """ + # if the value looks like an id, it's a download access id, so redirect + try: + validator = Valid_id() + download_access_id = validator( download_access_id ) + except ValueError: + raise cherrypy.NotFound + + return dict( + redirect = u"/users/download_thanks/access_id=%s" % download_access_id, + ) + @expose( view = Front_page ) @strongly_expire @end_transaction @@ -350,9 +373,10 @@ class Root( object ): @end_transaction @grab_user_id @validate( + upgrade = Valid_bool( none_okay = True ), user_id = Valid_id( none_okay = True ), ) - def download( self, user_id = None ): + def download( self, upgrade = False, user_id = None ): """ Provide the information necessary to display the Luminotes download page. """ @@ -363,7 +387,8 @@ class Root( object ): else: result[ "first_notebook" ] = None - result[ "download_button" ] = self.__settings[ u"global" ].get( u"luminotes.download_button" ) + result[ "download_products" ] = self.__settings[ u"global" ].get( u"luminotes.download_products" ) + result[ "upgrade" ] = upgrade return result diff --git a/controller/Users.py b/controller/Users.py index 341058d..71ede4c 100644 --- a/controller/Users.py +++ b/controller/Users.py @@ -2,6 +2,8 @@ import re import urllib import urllib2 import cherrypy +import smtplib +from email import Message from pytz import utc from datetime import datetime, timedelta from model.User import User @@ -9,6 +11,7 @@ from model.Group import Group from model.Notebook import Notebook from model.Note import Note from model.Password_reset import Password_reset +from model.Download_access import Download_access from model.Invite import Invite from Expose import expose from Validate import validate, Valid_string, Valid_bool, Valid_int, Validation_error @@ -21,7 +24,10 @@ from view.Redeem_invite_note import Redeem_invite_note from view.Blank_page import Blank_page from view.Thanks_note import Thanks_note from view.Thanks_error_note import Thanks_error_note +from view.Thanks_download_note import Thanks_download_note +from view.Thanks_download_error_note import Thanks_download_error_note from view.Processing_note import Processing_note +from view.Processing_download_note import Processing_download_note from view.Form_submit_page import Form_submit_page @@ -181,7 +187,7 @@ class Users( object ): """ Controller for dealing with users, corresponding to the "/users" URL. """ - def __init__( self, database, http_url, https_url, support_email, payment_email, rate_plans ): + def __init__( self, database, http_url, https_url, support_email, payment_email, rate_plans, download_products ): """ Create a new Users object. @@ -195,8 +201,10 @@ class Users( object ): @param support_email: email address for support requests @type payment_email: unicode @param payment_email: email address for payment - @type rate_plans: [ { "name": unicode, "storage_quota_bytes": int } ] + @type rate_plans: [ { "name": unicode, ... } ] @param rate_plans: list of configured rate plans + @type download_products: [ { "name": unicode, ... } ] + @param download_products: list of configured downloadable products @rtype: Users @return: newly constructed Users """ @@ -206,6 +214,7 @@ class Users( object ): self.__support_email = support_email self.__payment_email = payment_email self.__rate_plans = rate_plans + self.__download_products = download_products def create_user( self, username, password = None, password_repeat = None, email_address = None, initial_rate_plan = None ): """ @@ -802,9 +811,6 @@ class Users( object ): @raise Password_reset_error: an error occured when sending the password reset email @raise Validation_error: one of the arguments is invalid """ - import smtplib - from email import Message - # check whether there are actually any users with the given email address users = self.__database.select_many( User, User.sql_load_by_email_address( email_address ) ) @@ -1252,6 +1258,9 @@ class Users( object ): self.__database.commit() + #PAYPAL_URL = u"https://www.sandbox.paypal.com/cgi-bin/webscr" + PAYPAL_URL = u"https://www.paypal.com/cgi-bin/webscr" + @expose( view = Blank_page ) @end_transaction def paypal_notify( self, **params ): @@ -1262,9 +1271,6 @@ class Users( object ): record in the database with their new rate plan. paypal_notify() is invoked by PayPal itself. """ - #PAYPAL_URL = u"https://www.sandbox.paypal.com/cgi-bin/webscr" - PAYPAL_URL = u"https://www.paypal.com/cgi-bin/webscr" - # check that payment_status is Completed payment_status = params.get( u"payment_status" ) if payment_status == u"Refunded": @@ -1283,19 +1289,117 @@ class Users( object ): raise Payment_error( u"unsupported mc_currency", params ) # verify item_number - plan_index = params.get( u"item_number" ) - if plan_index == None or plan_index == u"": + item_number = params.get( u"item_number" ) + if item_number == None or item_number == u"": return dict() # ignore this transaction if there's no item number - try: - plan_index = int( plan_index ) + int( item_number ) except ValueError: raise Payment_error( u"invalid item_number", params ) - if plan_index == 0 or plan_index >= len( self.__rate_plans ): - raise Payment_error( u"invalid item_number", params ) + product = None + for potential_product in self.__download_products: + if unicode( item_number ) == potential_product.get( u"item_number" ): + product = potential_product + + if product: + self.__paypal_notify_download( params, product, unicode( item_number ) ) + else: + plan_index = int( item_number ) + try: + rate_plan = self.__rate_plans[ plan_index ] + except IndexError: + raise Payment_error( u"invalid item_number", params ) + self.__paypal_notify_subscribe( params, rate_plan, plan_index ) + + return dict() + + TRANSACTION_ID_PATTERN = re.compile( u"^[a-zA-Z0-9]+$" ) + + def __paypal_notify_download( self, params, product, item_number ): + # verify that quantity * the expected fee == mc_gross + fee = float( product[ u"fee" ] ) + + try: + mc_gross = float( params.get( u"mc_gross" ) ) + if not mc_gross: raise ValueError() + except ( TypeError, ValueError ): + raise Payment_error( u"invalid mc_gross", params ) + + try: + quantity = float( params.get( u"quantity" ) ) + if not quantity: raise ValueError() + except ( TypeError, ValueError ): + raise Payment_error( u"invalid quantity", params ) + + if quantity * fee != mc_gross: + raise Payment_error( u"invalid mc_gross", params ) + + # verify item_name + item_name = params.get( u"item_name" ) + if item_name and product[ u"name" ].lower() not in item_name.lower(): + raise Payment_error( u"invalid item_name", params ) + + params[ u"cmd" ] = u"_notify-validate" + encoded_params = urllib.urlencode( params ) + + # verify txn_type + txn_type = params.get( u"txn_type" ) + if txn_type and txn_type != u"web_accept": + raise Payment_error( u"invalid txn_type", params ) + + # verify txn_id + txn_id = params.get( u"txn_id" ) + if not self.TRANSACTION_ID_PATTERN.search( txn_id ): + raise Payment_error( u"invalid txn_id", params ) + + # ask paypal to verify the request + request = urllib2.Request( self.PAYPAL_URL ) + request.add_header( u"Content-type", u"application/x-www-form-urlencoded" ) + request_file = urllib2.urlopen( self.PAYPAL_URL, encoded_params ) + result = request_file.read() + + if result != u"VERIFIED": + raise Payment_error( result, params ) + + # update the database with a record of the transaction, thereby giving the user access to the + # download + download_access_id = self.__database.next_id( Download_access, commit = False ) + download_access = Download_access.create( download_access_id, item_number, txn_id ) + self.__database.save( download_access, commit = False ) + self.__database.commit() + + # using the reported payer email, send the user an email with a download link + email_address = params.get( u"payer_email" ) + if not email_address: + return + + # create an email message with a unique invitation link + message = Message.Message() + message[ u"From" ] = u"Luminotes personal wiki <%s>" % self.__support_email + message[ u"To" ] = email_address + message[ u"Subject" ] = u"Luminotes Desktop download" + + payload = \ + u"Thank you for purchasing Luminotes Desktop!\n\n" + \ + u"To download the installer, please follow this link:\n\n" + \ + u"%s/d/%s\n\n" % ( self.__https_url or self.__http_url, download_access_id ) + \ + u"You can use this link anytime to download Luminotes Desktop or upgrade\n" + \ + u"to new versions as they are released. So you should probably keep the" + \ + u"link around.\n\n" + \ + u"If you have any questions, please email support@luminotes.com\n\n" + \ + u"Enjoy!" + + message.set_payload( payload ) + + # send the message out through localhost's smtp server + server = smtplib.SMTP() + server.connect() + server.sendmail( message[ u"From" ], [ email_address ], message.as_string() ) + server.quit() + + def __paypal_notify_subscribe( self, params, rate_plan, plan_index ): # verify mc_gross - rate_plan = self.__rate_plans[ plan_index ] fee = u"%0.2f" % rate_plan[ u"fee" ] yearly_fee = u"%0.2f" % rate_plan[ u"yearly_fee" ] mc_gross = params.get( u"mc_gross" ) @@ -1329,9 +1433,9 @@ class Users( object ): encoded_params = urllib.urlencode( params ) # ask paypal to verify the request - request = urllib2.Request( PAYPAL_URL ) + request = urllib2.Request( self.PAYPAL_URL ) request.add_header( u"Content-type", u"application/x-www-form-urlencoded" ) - request_file = urllib2.urlopen( PAYPAL_URL, encoded_params ) + request_file = urllib2.urlopen( self.PAYPAL_URL, encoded_params ) result = request_file.read() if result != u"VERIFIED": @@ -1366,8 +1470,6 @@ class Users( object ): else: raise Payment_error( "unknown txn_type", params ) - return dict() - def update_groups( self, user ): """ Update a user's group membership as a result of a rate plan change. This method does not commit @@ -1468,6 +1570,93 @@ class Users( object ): def rate_plan( self, plan_index ): return self.__rate_plans[ plan_index ] + @expose( view = Main_page ) + @end_transaction + @grab_user_id + def thanks_download( self, **params ): + """ + Provide the information necessary to display the download thanks page, including a product + download link. This information can be accessed with an item_number and either a txn_id or a + download access_id. + """ + item_number = params.get( u"item_number" ) + try: + item_number = int( item_number ) + except ( TypeError, ValueError ): + raise Payment_error( u"invalid item_number", params ) + + # if a valid txn_id is provided, redirect to this page with the corresponding access_id. + # that way, if the user bookmarks the page, they'll bookmark it with the access_id rather + # than the txn_id + txn_id = params.get( u"txn_id" ) + if txn_id: + if not self.TRANSACTION_ID_PATTERN.search( txn_id ): + raise Payment_error( u"invalid txn_id", params ) + + download_access = self.__database.select_one( Download_access, Download_access.sql_load_by_transaction_id( txn_id ) ) + if download_access: + return dict( + redirect = u"/users/thanks_download?access_id=%s&item_number=%s" % ( download_access.object_id, item_number ) + ) + + download_access_id = params.get( u"access_id" ) + download_url = None + + if download_access_id: + try: + Valid_id()( download_access_id ) + except ValueError: + raise Payment_error( u"invalid access_id", params ) + + download_access = self.__database.load( Download_access, download_access_id ) + if download_access: + if download_access.item_number != unicode( item_number ): + raise Payment_error( u"incorrect item_number", params ) + download_url = u"%s/files/download_product/access_id=%s&item_number=%s" % \ + ( self.__https_url or u"", download_access_id, item_number ) + + if not txn_id and not download_access_id: + raise Payment_error( u"either txn_id or access_id required", params ) + + anonymous = self.__database.select_one( User, User.sql_load_by_username( u"anonymous" ), use_cache = True ) + if anonymous: + main_notebook = self.__database.select_one( Notebook, anonymous.sql_load_notebooks( undeleted_only = True ) ) + else: + main_notebook = None + + result = self.current( params.get( u"user_id" ) ) + + retry_count = params.get( u"retry_count", "" ) + try: + retry_count = int( retry_count ) + except ValueError: + retry_count = None + + # if there's no download access or we've retried too many times, give up and display an error + RETRY_TIMEOUT = 15 + if download_url is None and retry_count > RETRY_TIMEOUT: + note = Thanks_download_error_note() + # if the rate plan of the subscription matches the user's current rate plan, success + elif download_url: + note = Thanks_download_note( download_url ) + result[ "conversion" ] = "download_%s" % item_number + # otherwise, display an auto-reloading "processing..." page + else: + note = Processing_download_note( download_access_id, item_number, retry_count ) + + 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(), use_cache = True ) + result[ "note_read_write" ] = False + result[ "notes" ] = [ Note.create( + object_id = u"thanks", + contents = unicode( note ), + notebook_id = main_notebook.object_id, + ) ] + result[ "invites" ] = [] + + return result + @expose( view = Json ) @end_transaction @grab_user_id diff --git a/controller/test/Test_controller.py b/controller/test/Test_controller.py index 23e39b5..49a714a 100644 --- a/controller/test/Test_controller.py +++ b/controller/test/Test_controller.py @@ -96,6 +96,21 @@ class Test_controller( object ): u"yearly_button": u"[yearly or here user %s!] button", }, ], + "luminotes.download_products": [ + { + "name": "local desktop extravaganza", + "designed_for": "individuals", + "storage_quota_bytes": None, + "included_users": 1, + "notebook_sharing": False, + "notebook_collaboration": False, + "user_admin": False, + "fee": "30.00", + "item_number": "5000", + "filename": "test.exe", + "button": u"", + }, + ], }, u"/files/download": { u"stream_response": True, diff --git a/controller/test/Test_files.py b/controller/test/Test_files.py index 90cf732..c5f1e75 100644 --- a/controller/test/Test_files.py +++ b/controller/test/Test_files.py @@ -1,5 +1,6 @@ # -*- coding: utf8 -*- +import os import time import types import urllib @@ -14,6 +15,7 @@ from model.Note import Note from model.User import User from model.Invite import Invite from model.File import File +from model.Download_access import Download_access from controller.Notebooks import Access_error from controller.Files import Upload_file, Parse_error @@ -90,6 +92,11 @@ class Test_files( Test_controller ): Upload_file.exists = exists Upload_file.close = close + # write a test product file + test_product_file = file( u"products/test.exe", "wb" ) + test_product_file.write( self.file_data ) + test_product_file.close() + self.make_users() self.make_notebooks() self.database.commit() @@ -128,6 +135,8 @@ class Test_files( Test_controller ): if self.upload_thread: self.upload_thread.join() + os.remove( u"products/test.exe" ) + def test_download( self, filename = None, quote_filename = None, file_data = None, preview = None ): self.login() @@ -327,6 +336,139 @@ class Test_files( Test_controller ): assert u"access" in result[ u"body" ][ 0 ] + def test_download_product( self ): + access_id = u"wheeaccessid" + item_number = u"5000" + transaction_id = u"txn" + + self.login() + + download_access = Download_access.create( access_id, item_number, transaction_id ) + self.database.save( download_access ) + + result = self.http_get( + "/files/download_product?access_id=%s&item_number=%s" % ( access_id, item_number ), + session_id = self.session_id, + ) + + headers = result[ u"headers" ] + assert headers + assert headers[ u"Content-Type" ] == u"application/octet-stream" + + filename = u"test.exe".encode( "utf8" ) + assert headers[ u"Content-Disposition" ] == 'attachment; filename="%s"' % filename + + gen = result[ u"body" ] + assert isinstance( gen, types.GeneratorType ) + pieces = [] + + try: + for piece in gen: + pieces.append( piece ) + except AttributeError, exc: + if u"session_storage" not in str( exc ): + raise exc + + file_data = "".join( pieces ) + assert file_data == self.file_data + + def test_download_product_without_login( self ): + access_id = u"wheeaccessid" + item_number = u"5000" + transaction_id = u"txn" + + download_access = Download_access.create( access_id, item_number, transaction_id ) + self.database.save( download_access ) + + result = self.http_get( + "/files/download_product?access_id=%s&item_number=%s" % ( access_id, item_number ), + ) + + headers = result[ u"headers" ] + assert headers + assert headers[ u"Content-Type" ] == u"application/octet-stream" + + filename = u"test.exe".encode( "utf8" ) + assert headers[ u"Content-Disposition" ] == 'attachment; filename="%s"' % filename + + gen = result[ u"body" ] + assert isinstance( gen, types.GeneratorType ) + pieces = [] + + try: + for piece in gen: + pieces.append( piece ) + except AttributeError, exc: + if u"session_storage" not in str( exc ): + raise exc + + file_data = "".join( pieces ) + assert file_data == self.file_data + + def test_download_product_unknown_access_id( self ): + access_id = u"wheeaccessid" + item_number = u"5000" + transaction_id = u"txn" + + self.login() + + download_access = Download_access.create( access_id, item_number, transaction_id ) + self.database.save( download_access ) + + result = self.http_get( + "/files/download_product?access_id=%s&item_number=%s" % ( u"unknownid", item_number ), + session_id = self.session_id, + ) + + assert u"access" in result[ u"body" ][ 0 ] + headers = result[ u"headers" ] + assert headers + assert headers[ u"Content-Type" ] == u"text/html" + assert not headers.get( u"Content-Disposition" ) + + def test_download_product_unknown_item_number( self ): + access_id = u"wheeaccessid" + item_number = u"5000" + transaction_id = u"txn" + + self.login() + + download_access = Download_access.create( access_id, item_number, transaction_id ) + self.database.save( download_access ) + + result = self.http_get( + "/files/download_product?access_id=%s&item_number=%s" % ( access_id, u"1137" ), + session_id = self.session_id, + ) + + assert u"access" in result[ u"body" ][ 0 ] + headers = result[ u"headers" ] + assert headers + assert headers[ u"Content-Type" ] == u"text/html" + assert not headers.get( u"Content-Disposition" ) + + def test_download_product_missing_file( self ): + access_id = u"wheeaccessid" + item_number = u"5000" + transaction_id = u"txn" + self.settings[ u"global" ][ u"luminotes.download_products" ][ 0 ][ u"filename" ] = u"notthere.exe" + + self.login() + + download_access = Download_access.create( access_id, item_number, transaction_id ) + self.database.save( download_access ) + + result = self.http_get( + "/files/download_product?access_id=%s&item_number=%s" % ( access_id, item_number ), + session_id = self.session_id, + ) + + assert u"access" in result[ u"body" ][ 0 ] + headers = result[ u"headers" ] + assert headers + assert headers[ u"Content-Type" ] == u"text/html" + assert not headers.get( u"Content-Disposition" ) + def test_preview( self ): self.login() diff --git a/controller/test/Test_root.py b/controller/test/Test_root.py index 4f6fa0b..ccfdebe 100644 --- a/controller/test/Test_root.py +++ b/controller/test/Test_root.py @@ -538,3 +538,9 @@ class Test_root( Test_controller ): result = self.http_get( "/i/%s" % invite_id ) assert result[ u"redirect" ] == u"/users/redeem_invite/%s" % invite_id + + def test_download_thanks( self ): + download_access_id = u"foobarbaz" + result = self.http_get( "/d/%s" % download_access_id ) + + assert result[ u"redirect" ] == u"/users/download_thanks/access_id=%s" % download_access_id diff --git a/controller/test/Test_users.py b/controller/test/Test_users.py index 53387eb..8113a09 100644 --- a/controller/test/Test_users.py +++ b/controller/test/Test_users.py @@ -7,11 +7,13 @@ from nose.tools import raises from datetime import datetime, timedelta from Test_controller import Test_controller import Stub_urllib2 +from config.Version import VERSION from model.User import User from model.Group import Group from model.Notebook import Notebook from model.Note import Note from model.Password_reset import Password_reset +from model.Download_access import Download_access from model.Invite import Invite from controller.Users import Invite_error, Payment_error import controller.Users as Users @@ -3944,6 +3946,150 @@ class Test_users( Test_controller ): user = self.database.load( User, self.user.object_id ) assert user.rate_plan == 1 + DOWNLOAD_PAYMENT_DATA = { + u"last_name": u"User", + u"txn_id": u"txn", + u"receiver_email": u"unittest@luminotes.com", + u"payment_status": u"Completed", + u"payment_gross": u"30.00", + u"residence_country": u"US", + u"payer_status": u"verified", + u"txn_type": u"web_accept", + u"payment_date": u"15:38:18 Jan 10 2008 PST", + u"first_name": u"Test", + u"item_name": u"local desktop extravaganza", + u"charset": u"windows-1252", + u"notify_version": u"2.4", + u"item_number": u"5000", + u"receiver_id": u"rcv", + u"business": u"unittest@luminotes.com", + u"payer_id": u"pyr", + u"verify_sign": u"vfy", + u"payment_fee": u"1.19", + u"mc_fee": u"1.19", + u"mc_currency": u"USD", + u"shipping": u"0.00", + u"payer_email": u"buyer@luminotes.com", + u"payment_type": u"instant", + u"mc_gross": u"30.00", + u"quantity": u"1", + } + + def __assert_download_payment_success( self, result, expect_email = True ): + assert len( result ) == 1 + assert result.get( u"session_id" ) + assert Stub_urllib2.result == u"VERIFIED" + assert Stub_urllib2.headers.get( u"Content-type" ) == u"application/x-www-form-urlencoded" + assert Stub_urllib2.url.startswith( "https://" ) + assert u"paypal.com" in Stub_urllib2.url + assert Stub_urllib2.encoded_params + + # verify that the user has been granted download access + download_access = self.database.select_one( Download_access, "select * from download_access order by revision desc limit 1;" ); + assert download_access + assert download_access.item_number == u"5000" + assert download_access.transaction_id == u"txn" + + if not expect_email: + return + + # verify that an email has been sent to the user + assert smtplib.SMTP.connected == False + assert "<%s>" % self.settings[ u"global" ][ u"luminotes.support_email" ] in smtplib.SMTP.from_address + assert smtplib.SMTP.to_addresses == [ u"buyer@luminotes.com" ] + assert u"Thank you" in smtplib.SMTP.message + assert u"download" in smtplib.SMTP.message + assert u"upgrade" in smtplib.SMTP.message + + expected_download_link = u"%s/d/%s" % \ + ( self.settings[ u"global" ][ u"luminotes.https_url" ], download_access.object_id ) + assert expected_download_link in smtplib.SMTP.message + + def test_paypal_notify_download_payment( self ): + data = dict( self.DOWNLOAD_PAYMENT_DATA ) + result = self.http_post( "/users/paypal_notify", data ); + self.__assert_download_payment_success( result ) + + def test_paypal_notify_download_payment_multiple_quantity( self ): + data = dict( self.DOWNLOAD_PAYMENT_DATA ) + data[ u"mc_gross" ] = u"90.0" + data[ u"quantity" ] = u"3" + result = self.http_post( "/users/paypal_notify", data ); + self.__assert_download_payment_success( result ) + + def __assert_download_payment_error( self, result ): + assert u"error" in result + download_access = self.database.select_one( Download_access, "select * from download_access order by revision desc limit 1;" ); + assert not download_access + assert not smtplib.SMTP.message + + def test_paypal_notify_download_payment_missing_mc_gross( self ): + data = dict( self.DOWNLOAD_PAYMENT_DATA ) + del( data[ u"mc_gross" ] ) + result = self.http_post( "/users/paypal_notify", data ); + self.__assert_download_payment_error( result ) + + def test_paypal_notify_download_payment_none_mc_gross( self ): + data = dict( self.DOWNLOAD_PAYMENT_DATA ) + data[ u"mc_gross" ] = None + result = self.http_post( "/users/paypal_notify", data ); + self.__assert_download_payment_error( result ) + + def test_paypal_notify_download_payment_missing_quantity( self ): + data = dict( self.DOWNLOAD_PAYMENT_DATA ) + del( data[ u"quantity" ] ) + result = self.http_post( "/users/paypal_notify", data ); + self.__assert_download_payment_error( result ) + + def test_paypal_notify_download_payment_none_quantity( self ): + data = dict( self.DOWNLOAD_PAYMENT_DATA ) + data[ u"quantity" ] = None + result = self.http_post( "/users/paypal_notify", data ); + self.__assert_download_payment_error( result ) + + def test_paypal_notify_download_payment_quantity_mc_gross_mismatch( self ): + data = dict( self.DOWNLOAD_PAYMENT_DATA ) + data[ u"quantity" ] = u"2" + result = self.http_post( "/users/paypal_notify", data ); + self.__assert_download_payment_error( result ) + + def test_paypal_notify_download_payment_mc_gross_fee_mismatch( self ): + data = dict( self.DOWNLOAD_PAYMENT_DATA ) + data[ u"quantity" ] = u"2" + data[ u"mc_gross" ] = u"61.0" + result = self.http_post( "/users/paypal_notify", data ); + self.__assert_download_payment_error( result ) + + def test_paypal_notify_download_payment_invalid_item_name( self ): + data = dict( self.DOWNLOAD_PAYMENT_DATA ) + data[ u"item_name" ] = u"something unexpected" + result = self.http_post( "/users/paypal_notify", data ); + self.__assert_download_payment_error( result ) + + def test_paypal_notify_download_payment_partial_item_name( self ): + data = dict( self.DOWNLOAD_PAYMENT_DATA ) + data[ u"item_name" ] = u"ultra LOCAL DESKTOP extravaganza digital download!" + result = self.http_post( "/users/paypal_notify", data ); + self.__assert_download_payment_success( result ) + + def test_paypal_notify_download_payment_invalid_txn_type( self ): + data = dict( self.DOWNLOAD_PAYMENT_DATA ) + data[ u"txn_type" ] = u"web_wtf" + result = self.http_post( "/users/paypal_notify", data ); + self.__assert_download_payment_error( result ) + + def test_paypal_notify_download_payment_invalid_txn_id( self ): + data = dict( self.DOWNLOAD_PAYMENT_DATA ) + data[ u"txn_id" ] = u"not even remotely valid" + result = self.http_post( "/users/paypal_notify", data ); + self.__assert_download_payment_error( result ) + + def test_paypal_notify_download_payment_missing_payer_email( self ): + data = dict( self.DOWNLOAD_PAYMENT_DATA ) + data[ u"payer_email" ] = u"" + result = self.http_post( "/users/paypal_notify", data ); + self.__assert_download_payment_success( result, expect_email = False ) + def test_thanks( self ): self.user.rate_plan = 1 user = self.database.save( self.user ) @@ -4143,6 +4289,508 @@ class Test_users( Test_controller ): assert u"Thank you" in result[ u"notes" ][ 0 ].contents assert u"confirmation" in result[ u"notes" ][ 0 ].contents + def test_thanks_download( self ): + access_id = u"wheeaccessid" + item_number = u"5000" + transaction_id = u"txn" + + download_access = Download_access.create( access_id, item_number, transaction_id ) + self.database.save( download_access ) + + self.login() + + result = self.http_post( "/users/thanks_download", dict( + access_id = access_id, + item_number = item_number, + ), session_id = self.session_id ) + + assert result[ u"user" ].username == self.user.username + assert len( result[ u"notebooks" ] ) == 5 + notebook = [ notebook for notebook in result[ u"notebooks" ] if notebook.object_id == self.notebooks[ 0 ].object_id ][ 0 ] + assert notebook.object_id == self.notebooks[ 0 ].object_id + assert notebook.name == self.notebooks[ 0 ].name + assert notebook.read_write == True + assert notebook.owner == True + assert notebook.rank == 0 + + assert result[ u"login_url" ] == None + assert result[ u"logout_url" ] == self.settings[ u"global" ][ u"luminotes.https_url" ] + u"/users/logout" + + rate_plan = result[ u"rate_plan" ] + assert rate_plan + assert rate_plan[ u"name" ] == u"super" + assert rate_plan[ u"storage_quota_bytes" ] == 1337 * 10 + + assert result[ u"conversion" ] == u"download_5000" + assert result[ u"notebook" ].object_id == self.anon_notebook.object_id + assert len( result[ u"startup_notes" ] ) == 1 + assert result[ u"startup_notes" ][ 0 ].object_id == self.startup_note.object_id + assert result[ u"startup_notes" ][ 0 ].title == self.startup_note.title + assert result[ u"startup_notes" ][ 0 ].contents == self.startup_note.contents + assert result[ u"note_read_write" ] is False + + assert result[ u"notes" ] + assert len( result[ u"notes" ] ) == 1 + assert result[ u"notes" ][ 0 ].title == u"thank you" + assert result[ u"notes" ][ 0 ].notebook_id == self.anon_notebook.object_id + assert u"Thank you" in result[ u"notes" ][ 0 ].contents + assert u"Luminotes Desktop" in result[ u"notes" ][ 0 ].contents + assert u"Download" in result[ u"notes" ][ 0 ].contents + assert VERSION in result[ u"notes" ][ 0 ].contents + + expected_download_link = u"%s/files/download_product/access_id=%s&item_number=%s" % \ + ( self.settings[ u"global" ][ u"luminotes.https_url" ], access_id, item_number ) + assert expected_download_link in result[ u"notes" ][ 0 ].contents + + def test_thanks_download_without_login( self ): + access_id = u"wheeaccessid" + item_number = u"5000" + transaction_id = u"txn" + + download_access = Download_access.create( access_id, item_number, transaction_id ) + self.database.save( download_access ) + + result = self.http_post( "/users/thanks_download", dict( + access_id = access_id, + item_number = item_number, + ) ) + + assert result[ u"user" ].username == self.anonymous.username + assert len( result[ u"notebooks" ] ) == 1 + + assert result[ u"login_url" ] + assert result[ u"logout_url" ] + + rate_plan = result[ u"rate_plan" ] + assert rate_plan + assert rate_plan[ u"name" ] == u"super" + assert rate_plan[ u"storage_quota_bytes" ] == 1337 * 10 + + assert result[ u"conversion" ] == u"download_5000" + assert result[ u"notebook" ].object_id == self.anon_notebook.object_id + assert len( result[ u"startup_notes" ] ) == 1 + assert result[ u"startup_notes" ][ 0 ].object_id == self.startup_note.object_id + assert result[ u"startup_notes" ][ 0 ].title == self.startup_note.title + assert result[ u"startup_notes" ][ 0 ].contents == self.startup_note.contents + assert result[ u"note_read_write" ] is False + + assert result[ u"notes" ] + assert len( result[ u"notes" ] ) == 1 + assert result[ u"notes" ][ 0 ].title == u"thank you" + assert result[ u"notes" ][ 0 ].notebook_id == self.anon_notebook.object_id + assert u"Thank you" in result[ u"notes" ][ 0 ].contents + assert u"Luminotes Desktop" in result[ u"notes" ][ 0 ].contents + assert u"Download" in result[ u"notes" ][ 0 ].contents + assert VERSION in result[ u"notes" ][ 0 ].contents + + expected_download_link = u"%s/files/download_product/access_id=%s&item_number=%s" % \ + ( self.settings[ u"global" ][ u"luminotes.https_url" ], access_id, item_number ) + assert expected_download_link in result[ u"notes" ][ 0 ].contents + + def test_thanks_download_invalid_item_number( self ): + access_id = u"wheeaccessid" + item_number = u"5000abc" + transaction_id = u"txn" + + download_access = Download_access.create( access_id, item_number, transaction_id ) + self.database.save( download_access ) + + self.login() + + result = self.http_post( "/users/thanks_download", dict( + access_id = access_id, + item_number = item_number, + ), session_id = self.session_id ) + + assert u"error" in result + + def test_thanks_download_none_item_number( self ): + access_id = u"wheeaccessid" + item_number = None + transaction_id = u"txn" + + download_access = Download_access.create( access_id, item_number, transaction_id ) + self.database.save( download_access ) + + self.login() + + result = self.http_post( "/users/thanks_download", dict( + access_id = access_id, + item_number = item_number, + ), session_id = self.session_id ) + + assert u"error" in result + + def test_thanks_download_missing_item_number( self ): + access_id = u"wheeaccessid" + transaction_id = u"txn" + + self.login() + + result = self.http_post( "/users/thanks_download", dict( + access_id = access_id, + ), session_id = self.session_id ) + + assert u"error" in result + + def test_thanks_download_incorrect_item_number( self ): + access_id = u"wheeaccessid" + item_number = u"5000" + transaction_id = u"txn" + + self.login() + + download_access = Download_access.create( access_id, item_number, transaction_id ) + self.database.save( download_access ) + + result = self.http_post( "/users/thanks_download", dict( + access_id = access_id, + item_number = u"1234", + ), session_id = self.session_id ) + + assert u"error" in result + + def test_thanks_download_txn_id( self ): + access_id = u"wheeaccessid" + item_number = u"5000" + transaction_id = u"txn" + + download_access = Download_access.create( access_id, item_number, transaction_id ) + self.database.save( download_access ) + + self.login() + + result = self.http_post( "/users/thanks_download", dict( + txn_id = transaction_id, + item_number = item_number, + ), session_id = self.session_id ) + + redirect = result.get( u"redirect" ) + expected_redirect = "/users/thanks_download?access_id=%s&item_number=%s" % ( access_id, item_number ) + assert redirect == expected_redirect + + def test_thanks_download_invalid_txn_id( self ): + access_id = u"wheeaccessid" + item_number = u"5000" + transaction_id = u"invalid txn id" + + download_access = Download_access.create( access_id, item_number, transaction_id ) + self.database.save( download_access ) + + self.login() + + result = self.http_post( "/users/thanks_download", dict( + txn_id = transaction_id, + item_number = item_number, + ), session_id = self.session_id ) + + assert u"error" in result + + def test_thanks_download_not_yet_paid( self ): + access_id = u"wheeaccessid" + item_number = u"5000" + transaction_id = u"txn" + + self.login() + + result = self.http_post( "/users/thanks_download", dict( + access_id = access_id, + item_number = item_number, + ), session_id = self.session_id ) + + # an unknown transaction id might just mean we're still waiting for the transaction to come in, + # so expect a retry + assert result[ u"user" ].username == self.user.username + assert len( result[ u"notebooks" ] ) == 5 + notebook = [ notebook for notebook in result[ u"notebooks" ] if notebook.object_id == self.notebooks[ 0 ].object_id ][ 0 ] + assert notebook.object_id == self.notebooks[ 0 ].object_id + assert notebook.name == self.notebooks[ 0 ].name + assert notebook.read_write == True + assert notebook.owner == True + assert notebook.rank == 0 + + assert result[ u"login_url" ] == None + assert result[ u"logout_url" ] == self.settings[ u"global" ][ u"luminotes.https_url" ] + u"/users/logout" + + rate_plan = result[ u"rate_plan" ] + assert rate_plan + assert rate_plan[ u"name" ] == u"super" + assert rate_plan[ u"storage_quota_bytes" ] == 1337 * 10 + + assert not result.get( u"conversion" ) + assert result[ u"notebook" ].object_id == self.anon_notebook.object_id + assert len( result[ u"startup_notes" ] ) == 1 + assert result[ u"startup_notes" ][ 0 ].object_id == self.startup_note.object_id + assert result[ u"startup_notes" ][ 0 ].title == self.startup_note.title + assert result[ u"startup_notes" ][ 0 ].contents == self.startup_note.contents + assert result[ u"note_read_write" ] is False + + assert result[ u"notes" ] + assert len( result[ u"notes" ] ) == 1 + assert u"processing" in result[ u"notes" ][ 0 ].title + assert result[ u"notes" ][ 0 ].notebook_id == self.anon_notebook.object_id + assert u"being processed" in result[ u"notes" ][ 0 ].contents + assert u"retry_count=1" in result[ u"notes" ][ 0 ].contents + + def test_thanks_download_not_yet_paid_with_retry( self ): + access_id = u"wheeaccessid" + item_number = u"5000" + transaction_id = u"txn" + + self.login() + + result = self.http_post( "/users/thanks_download", dict( + access_id = access_id, + item_number = item_number, + retry_count = u"3", + ), session_id = self.session_id ) + + # an unknown transaction id might just mean we're still waiting for the transaction to come in, + # so expect a retry + assert result[ u"user" ].username == self.user.username + assert len( result[ u"notebooks" ] ) == 5 + notebook = [ notebook for notebook in result[ u"notebooks" ] if notebook.object_id == self.notebooks[ 0 ].object_id ][ 0 ] + assert notebook.object_id == self.notebooks[ 0 ].object_id + assert notebook.name == self.notebooks[ 0 ].name + assert notebook.read_write == True + assert notebook.owner == True + assert notebook.rank == 0 + + assert result[ u"login_url" ] == None + assert result[ u"logout_url" ] == self.settings[ u"global" ][ u"luminotes.https_url" ] + u"/users/logout" + + rate_plan = result[ u"rate_plan" ] + assert rate_plan + assert rate_plan[ u"name" ] == u"super" + assert rate_plan[ u"storage_quota_bytes" ] == 1337 * 10 + + assert not result.get( u"conversion" ) + assert result[ u"notebook" ].object_id == self.anon_notebook.object_id + assert len( result[ u"startup_notes" ] ) == 1 + assert result[ u"startup_notes" ][ 0 ].object_id == self.startup_note.object_id + assert result[ u"startup_notes" ][ 0 ].title == self.startup_note.title + assert result[ u"startup_notes" ][ 0 ].contents == self.startup_note.contents + assert result[ u"note_read_write" ] is False + + assert result[ u"notes" ] + assert len( result[ u"notes" ] ) == 1 + assert u"processing" in result[ u"notes" ][ 0 ].title + assert result[ u"notes" ][ 0 ].notebook_id == self.anon_notebook.object_id + assert u"being processed" in result[ u"notes" ][ 0 ].contents + assert u"retry_count=4" in result[ u"notes" ][ 0 ].contents + + def test_thanks_download_not_yet_paid_with_retry_timeout( self ): + access_id = u"wheeaccessid" + item_number = u"5000" + transaction_id = u"txn" + + self.login() + + result = self.http_post( "/users/thanks_download", dict( + access_id = access_id, + item_number = item_number, + retry_count = u"16", + ), session_id = self.session_id ) + + # an unknown transaction id might just mean we're still waiting for the transaction to come in, + # so expect a retry + assert result[ u"user" ].username == self.user.username + assert len( result[ u"notebooks" ] ) == 5 + notebook = [ notebook for notebook in result[ u"notebooks" ] if notebook.object_id == self.notebooks[ 0 ].object_id ][ 0 ] + assert notebook.object_id == self.notebooks[ 0 ].object_id + assert notebook.name == self.notebooks[ 0 ].name + assert notebook.read_write == True + assert notebook.owner == True + assert notebook.rank == 0 + + assert result[ u"login_url" ] == None + assert result[ u"logout_url" ] == self.settings[ u"global" ][ u"luminotes.https_url" ] + u"/users/logout" + + rate_plan = result[ u"rate_plan" ] + assert rate_plan + assert rate_plan[ u"name" ] == u"super" + assert rate_plan[ u"storage_quota_bytes" ] == 1337 * 10 + + assert not result.get( u"conversion" ) + assert result[ u"notebook" ].object_id == self.anon_notebook.object_id + assert len( result[ u"startup_notes" ] ) == 1 + assert result[ u"startup_notes" ][ 0 ].object_id == self.startup_note.object_id + assert result[ u"startup_notes" ][ 0 ].title == self.startup_note.title + assert result[ u"startup_notes" ][ 0 ].contents == self.startup_note.contents + assert result[ u"note_read_write" ] is False + + assert result[ u"notes" ] + assert len( result[ u"notes" ] ) == 1 + assert result[ u"notes" ][ 0 ].title == u"thank you" + assert result[ u"notes" ][ 0 ].notebook_id == self.anon_notebook.object_id + assert u"Thank you" in result[ u"notes" ][ 0 ].contents + assert u"confirmation" in result[ u"notes" ][ 0 ].contents + + def test_thanks_download_not_yet_paid_txn_id( self ): + access_id = u"wheeaccessid" + item_number = u"5000" + transaction_id = u"txn" + + self.login() + + result = self.http_post( "/users/thanks_download", dict( + txn_id = transaction_id, + item_number = item_number, + ), session_id = self.session_id ) + + # an unknown transaction id might just mean we're still waiting for the transaction to come in, + # so expect a retry + assert result[ u"user" ].username == self.user.username + assert len( result[ u"notebooks" ] ) == 5 + notebook = [ notebook for notebook in result[ u"notebooks" ] if notebook.object_id == self.notebooks[ 0 ].object_id ][ 0 ] + assert notebook.object_id == self.notebooks[ 0 ].object_id + assert notebook.name == self.notebooks[ 0 ].name + assert notebook.read_write == True + assert notebook.owner == True + assert notebook.rank == 0 + + assert result[ u"login_url" ] == None + assert result[ u"logout_url" ] == self.settings[ u"global" ][ u"luminotes.https_url" ] + u"/users/logout" + + rate_plan = result[ u"rate_plan" ] + assert rate_plan + assert rate_plan[ u"name" ] == u"super" + assert rate_plan[ u"storage_quota_bytes" ] == 1337 * 10 + + assert not result.get( u"conversion" ) + assert result[ u"notebook" ].object_id == self.anon_notebook.object_id + assert len( result[ u"startup_notes" ] ) == 1 + assert result[ u"startup_notes" ][ 0 ].object_id == self.startup_note.object_id + assert result[ u"startup_notes" ][ 0 ].title == self.startup_note.title + assert result[ u"startup_notes" ][ 0 ].contents == self.startup_note.contents + assert result[ u"note_read_write" ] is False + + assert result[ u"notes" ] + assert len( result[ u"notes" ] ) == 1 + assert u"processing" in result[ u"notes" ][ 0 ].title + assert result[ u"notes" ][ 0 ].notebook_id == self.anon_notebook.object_id + assert u"being processed" in result[ u"notes" ][ 0 ].contents + assert u"retry_count=1" in result[ u"notes" ][ 0 ].contents + + def test_thanks_download_not_yet_paid_txn_id_with_retry( self ): + access_id = u"wheeaccessid" + item_number = u"5000" + transaction_id = u"txn" + + self.login() + + result = self.http_post( "/users/thanks_download", dict( + txn_id = transaction_id, + item_number = item_number, + retry_count = u"3", + ), session_id = self.session_id ) + + # an unknown transaction id might just mean we're still waiting for the transaction to come in, + # so expect a retry + assert result[ u"user" ].username == self.user.username + assert len( result[ u"notebooks" ] ) == 5 + notebook = [ notebook for notebook in result[ u"notebooks" ] if notebook.object_id == self.notebooks[ 0 ].object_id ][ 0 ] + assert notebook.object_id == self.notebooks[ 0 ].object_id + assert notebook.name == self.notebooks[ 0 ].name + assert notebook.read_write == True + assert notebook.owner == True + assert notebook.rank == 0 + + assert result[ u"login_url" ] == None + assert result[ u"logout_url" ] == self.settings[ u"global" ][ u"luminotes.https_url" ] + u"/users/logout" + + rate_plan = result[ u"rate_plan" ] + assert rate_plan + assert rate_plan[ u"name" ] == u"super" + assert rate_plan[ u"storage_quota_bytes" ] == 1337 * 10 + + assert not result.get( u"conversion" ) + assert result[ u"notebook" ].object_id == self.anon_notebook.object_id + assert len( result[ u"startup_notes" ] ) == 1 + assert result[ u"startup_notes" ][ 0 ].object_id == self.startup_note.object_id + assert result[ u"startup_notes" ][ 0 ].title == self.startup_note.title + assert result[ u"startup_notes" ][ 0 ].contents == self.startup_note.contents + assert result[ u"note_read_write" ] is False + + assert result[ u"notes" ] + assert len( result[ u"notes" ] ) == 1 + assert u"processing" in result[ u"notes" ][ 0 ].title + assert result[ u"notes" ][ 0 ].notebook_id == self.anon_notebook.object_id + assert u"being processed" in result[ u"notes" ][ 0 ].contents + assert u"retry_count=4" in result[ u"notes" ][ 0 ].contents + + def test_thanks_download_not_yet_paid_txn_id_with_retry_timeout( self ): + access_id = u"wheeaccessid" + item_number = u"5000" + transaction_id = u"txn" + + self.login() + + result = self.http_post( "/users/thanks_download", dict( + txn_id = transaction_id, + item_number = item_number, + retry_count = u"16", + ), session_id = self.session_id ) + + # an unknown transaction id might just mean we're still waiting for the transaction to come in, + # so expect a retry + assert result[ u"user" ].username == self.user.username + assert len( result[ u"notebooks" ] ) == 5 + notebook = [ notebook for notebook in result[ u"notebooks" ] if notebook.object_id == self.notebooks[ 0 ].object_id ][ 0 ] + assert notebook.object_id == self.notebooks[ 0 ].object_id + assert notebook.name == self.notebooks[ 0 ].name + assert notebook.read_write == True + assert notebook.owner == True + assert notebook.rank == 0 + + assert result[ u"login_url" ] == None + assert result[ u"logout_url" ] == self.settings[ u"global" ][ u"luminotes.https_url" ] + u"/users/logout" + + rate_plan = result[ u"rate_plan" ] + assert rate_plan + assert rate_plan[ u"name" ] == u"super" + assert rate_plan[ u"storage_quota_bytes" ] == 1337 * 10 + + assert not result.get( u"conversion" ) + assert result[ u"notebook" ].object_id == self.anon_notebook.object_id + assert len( result[ u"startup_notes" ] ) == 1 + assert result[ u"startup_notes" ][ 0 ].object_id == self.startup_note.object_id + assert result[ u"startup_notes" ][ 0 ].title == self.startup_note.title + assert result[ u"startup_notes" ][ 0 ].contents == self.startup_note.contents + assert result[ u"note_read_write" ] is False + + assert result[ u"notes" ] + assert len( result[ u"notes" ] ) == 1 + assert result[ u"notes" ][ 0 ].title == u"thank you" + assert result[ u"notes" ][ 0 ].notebook_id == self.anon_notebook.object_id + assert u"Thank you" in result[ u"notes" ][ 0 ].contents + assert u"confirmation" in result[ u"notes" ][ 0 ].contents + + def test_thanks_download_missing_txn_id_missing_access_id( self ): + item_number = u"5000" + + self.login() + + result = self.http_post( "/users/thanks_download", dict( + item_number = item_number, + ), session_id = self.session_id ) + + assert u"error" in result + + def test_thanks_download_invalid_access_id( self ): + access_id = u"invalid access id" + item_number = u"5000" + transaction_id = u"txn" + + self.login() + + result = self.http_post( "/users/thanks_download", dict( + access_id = access_id, + item_number = item_number, + ), session_id = self.session_id ) + + assert u"error" in result + def test_rate_plan( self ): plan_index = 1 rate_plan = cherrypy.root.users.rate_plan( plan_index ) diff --git a/model/Download_access.py b/model/Download_access.py new file mode 100644 index 0000000..1ff16f0 --- /dev/null +++ b/model/Download_access.py @@ -0,0 +1,75 @@ +from Persistent import Persistent, quote + + +class Download_access( Persistent ): + """ + Access for a particular user to a downloadable product. This object is used to create unique + per-customer product download links without requiring the user to have a Luminotes account. + """ + def __init__( self, object_id, revision = None, item_number = None, transaction_id = None ): + """ + Create a download access record with the given id. + + @type object_id: unicode + @param object_id: id of the download access + @type revision: datetime or NoneType + @param revision: revision timestamp of the object (optional, defaults to now) + @type item_number: unicode or NoneType + @param item_number: number of the item to which download access is granted (optional) + @type transaction_id: unicode or NoneType + @param transaction_id: payment processor id for the transaction used to pay for this download + (optional) + @rtype: Download_access + @return: newly constructed download access object + """ + Persistent.__init__( self, object_id, revision ) + self.__item_number = item_number + self.__transaction_id = transaction_id + + @staticmethod + def create( object_id, item_number = None, transaction_id = None ): + """ + Convenience constructor for creating a new download access object. + + @type item_number: unicode or NoneType + @param item_number: number of the item to which download access is granted (optional) + @type transaction_id: unicode or NoneType + @param transaction_id: payment processor id for the transaction used to pay for this download + (optional) + @rtype: Download_access + @return: newly constructed download access object + """ + return Download_access( object_id, item_number = item_number, transaction_id = transaction_id ) + + @staticmethod + def sql_load( object_id, revision = None ): + # download access objects don't store old revisions + if revision: + raise NotImplementedError() + + return "select id, revision, item_number, transaction_id from download_access where id = %s;" % quote( object_id ) + + @staticmethod + def sql_load_by_transaction_id( transaction_id ): + return "select id, revision, item_number, transaction_id from download_access where transaction_id = %s;" % quote( transaction_id ) + + @staticmethod + def sql_id_exists( object_id, revision = None ): + if revision: + raise NotImplementedError() + + return "select id from download_access where id = %s;" % quote( object_id ) + + def sql_exists( self ): + return Download_access.sql_id_exists( self.object_id ) + + def sql_create( self ): + return "insert into download_access ( id, revision, item_number, transaction_id ) values ( %s, %s, %s, %s );" % \ + ( quote( self.object_id ), quote( self.revision ), quote( self.__item_number ), quote( self.__transaction_id ) ) + + def sql_update( self ): + return "update download_access set revision = %s, item_number = %s, transaction_id = %s where id = %s;" % \ + ( quote( self.revision ), quote( self.__item_number ), quote( self.__transaction_id ), quote( self.object_id ) ) + + item_number = property( lambda self: self.__item_number ) + transaction_id = property( lambda self: self.__transaction_id ) diff --git a/model/delta/1.5.0.sql b/model/delta/1.5.0.sql new file mode 100644 index 0000000..9ef62dc --- /dev/null +++ b/model/delta/1.5.0.sql @@ -0,0 +1,8 @@ +CREATE TABLE download_access ( + id text NOT NULL, + revision timestamp with time zone NOT NULL, + item_number text, + transaction_id text +); +ALTER TABLE ONLY download_access ADD CONSTRAINT download_access_pkey PRIMARY KEY (id); +CREATE INDEX download_access_transaction_id_index ON download_access USING btree (transaction_id); diff --git a/model/drop.sql b/model/drop.sql index 1a658d9..c392d37 100644 --- a/model/drop.sql +++ b/model/drop.sql @@ -7,6 +7,7 @@ DROP TABLE note; DROP VIEW notebook_current; DROP TABLE notebook; DROP TABLE password_reset; +DROP TABLE download_access; DROP TABLE user_notebook; DROP TABLE user_group; DROP TABLE invite; diff --git a/model/schema.sql b/model/schema.sql index 3d6ae2b..716f760 100644 --- a/model/schema.sql +++ b/model/schema.sql @@ -180,6 +180,21 @@ CREATE TABLE password_reset ( ALTER TABLE public.password_reset OWNER TO luminotes; + +-- Name: download_access; Type: TABLE; Schema: public; Owner: luminotes; Tablespace: +-- + +CREATE TABLE download_access ( + id text NOT NULL, + revision timestamp with time zone NOT NULL, + item_number text, + transaction_id text +); + + +ALTER TABLE public.download_access OWNER TO luminotes; + +-- -- -- Name: user_group; Type: TABLE; Schema: public; Owner: luminotes; Tablespace: -- @@ -256,6 +271,14 @@ ALTER TABLE ONLY password_reset ADD CONSTRAINT password_reset_pkey PRIMARY KEY (id); +-- +-- Name: download_access_pkey; Type: CONSTRAINT; Schema: public; Owner: luminotes; Tablespace: +-- + +ALTER TABLE ONLY download_access + ADD CONSTRAINT download_access_pkey PRIMARY KEY (id); + + -- -- Name: user_notebook_pkey; Type: CONSTRAINT; Schema: public; Owner: luminotes; Tablespace: -- @@ -327,6 +350,13 @@ CREATE INDEX note_notebook_id_title_index ON note USING btree (notebook_id, md5( CREATE INDEX password_reset_email_address_index ON password_reset USING btree (email_address); +-- +-- Name: download_access_transaction_id_index; Type: INDEX; Schema: public; Owner: luminotes; Tablespace: +-- + +CREATE INDEX download_access_transaction_id_index ON download_access USING btree (transaction_id); + + -- -- Name: search_index; Type: INDEX; Schema: public; Owner: luminotes; Tablespace: -- diff --git a/model/schema.sqlite b/model/schema.sqlite index 8ad9ce2..a8563eb 100644 --- a/model/schema.sqlite +++ b/model/schema.sqlite @@ -132,6 +132,17 @@ CREATE TABLE password_reset ( ); +-- Name: download_access; Type: TABLE; Schema: public; Owner: luminotes; Tablespace: +-- + +CREATE TABLE download_access ( + id text NOT NULL, + revision timestamp with time zone NOT NULL, + item_number text, + transaction_id text +); + + -- -- Name: user_group; Type: TABLE; Schema: public; Owner: luminotes; Tablespace: -- @@ -213,6 +224,13 @@ CREATE INDEX note_notebook_id_startup_index ON note (notebook_id, startup); CREATE INDEX note_notebook_id_title_index ON note (notebook_id, title); +-- +-- Name: password_reset_id_index; Type: INDEX; Schema: public; Owner: luminotes; Tablespace: +-- + +CREATE INDEX password_reset_id_index ON password_reset (id); + + -- -- Name: password_reset_email_address_index; Type: INDEX; Schema: public; Owner: luminotes; Tablespace: -- @@ -220,6 +238,18 @@ CREATE INDEX note_notebook_id_title_index ON note (notebook_id, title); CREATE INDEX password_reset_email_address_index ON password_reset (email_address); +-- Name: download_access_id_index; Type: INDEX; Schema: public; Owner: luminotes; Tablespace: +-- + +CREATE INDEX download_access_id_index ON password_reset (id); + + +-- Name: download_access_transaction_id_index; Type: INDEX; Schema: public; Owner: luminotes; Tablespace: +-- + +CREATE INDEX download_access_transaction_id_index ON download_access (transaction_id); + + -- -- Name: search_index; Type: INDEX; Schema: public; Owner: luminotes; Tablespace: -- diff --git a/model/test/Test_download_access.py b/model/test/Test_download_access.py new file mode 100644 index 0000000..d3a7fd3 --- /dev/null +++ b/model/test/Test_download_access.py @@ -0,0 +1,15 @@ +from model.Download_access import Download_access + + +class Test_download_access( object ): + def setUp( self ): + self.object_id = u"17" + self.item_number = u"999" + self.transaction_id = u"foooooooo234" + + self.download_access = Download_access.create( self.object_id, self.item_number, self.transaction_id ) + + def test_create( self ): + assert self.download_access.object_id == self.object_id + assert self.download_access.item_number == self.item_number + assert self.download_access.transaction_id == self.transaction_id diff --git a/products/.empty b/products/.empty new file mode 100644 index 0000000..e69de29 diff --git a/view/Download_page.py b/view/Download_page.py index dd2ddad..9fee1a2 100644 --- a/view/Download_page.py +++ b/view/Download_page.py @@ -4,9 +4,12 @@ from config.Version import VERSION class Download_page( Product_page ): - def __init__( self, user, notebooks, first_notebook, login_url, logout_url, rate_plan, groups, download_button ): + def __init__( self, user, notebooks, first_notebook, login_url, logout_url, rate_plan, groups, download_products, upgrade = False ): MEGABYTE = 1024 * 1024 + # for now, just assume there's a single download package + download_button = download_products[ 0 ].get( "button" ) + Product_page.__init__( self, user, @@ -33,6 +36,17 @@ class Download_page( Product_page ): class_ = u"upgrade_subtitle", ), Div( + upgrade and P( + B( "Upgrading:" ), + u""" + If you have already purchased Luminotes Desktop and would like to download a newer + version, simply follow the link you received after your purchase. Can't find + the link or need help? Please + """, + A( u"contact support", href = u"/contact_info" ), + u"for assistance.", + class_ = u"upgrade_text", + ) or None, Div( Img( src = u"/static/images/installer_screenshot.png", width = u"350", height = u"273" ), class_ = u"desktop_screenshot", diff --git a/view/Header.py b/view/Header.py index c9b11a2..4429275 100644 --- a/view/Header.py +++ b/view/Header.py @@ -17,7 +17,7 @@ class Header( Div ): A( title_image, href = u"http://luminotes.com/", target = "_new" ), Div( u"version", VERSION, u" | ", - A( u"upgrade", href = u"http://luminotes.com/pricing", target = "_new" ), u" | ", + A( u"upgrade", href = u"http://luminotes.com/download?upgrade=True", target = "_new" ), u" | ", A( u"support", href = u"http://luminotes.com/support", target = "_new" ), u" | ", A( u"blog", href = u"http://luminotes.com/blog", target = "_new" ), class_ = u"header_links", diff --git a/view/Processing_download_note.py b/view/Processing_download_note.py new file mode 100644 index 0000000..eb542a7 --- /dev/null +++ b/view/Processing_download_note.py @@ -0,0 +1,26 @@ +from Tags import Html, Head, Meta, H3, P + + +class Processing_download_note( Html ): + def __init__( self, download_access_id, item_number, retry_count ): + if not retry_count: + retry_count = 0 + + retry_count += 1 + + Html.__init__( + self, + Head( + Meta( + http_equiv = u"Refresh", + content = u"2; URL=/users/thanks_download?access_id=%s&item_number=%s&retry_count=%s" % + ( download_access_id, item_number, retry_count ), + ), + ), + H3( u"processing..." ), + P( + """ + Your payment is being processed. This shouldn't take more than a minute. Please wait... + """, + ), + ) diff --git a/view/Thanks_download_error_note.py b/view/Thanks_download_error_note.py new file mode 100644 index 0000000..2edcc1c --- /dev/null +++ b/view/Thanks_download_error_note.py @@ -0,0 +1,30 @@ +from Tags import Span, H3, P, A + + +class Thanks_download_error_note( Span ): + def __init__( self ): + Span.__init__( + self, + H3( u"thank you" ), + P( + u""" + Thank you for purchasing Luminotes Desktop! + """, + ), + P( + u""" + Luminotes has not yet received confirmation of your payment. Please + check back in a few minutes by refreshing this page, or check your + email for a Luminotes Desktop download message. + """ + ), + P( + """ + If your payment is not received within the next few minutes, please + """, + A( u"contact support", href = u"/contact_info", target = "_top" ), + u""" + for assistance. + """, + ), + ) diff --git a/view/Thanks_download_note.py b/view/Thanks_download_note.py new file mode 100644 index 0000000..124f293 --- /dev/null +++ b/view/Thanks_download_note.py @@ -0,0 +1,36 @@ +from Tags import Span, H3, P, A +from config.Version import VERSION + + +class Thanks_download_note( Span ): + def __init__( self, download_url ): + Span.__init__( + self, + H3( u"thank you" ), + P( + u""" + Thank you for purchasing Luminotes Desktop! + """, + ), + P( + A( u"Download Luminotes Desktop version %s" % VERSION, href = download_url ), + """ + and get started taking notes with your own personal wiki. + """, + ), + P( + u""" + It's a good idea to bookmark this page so that you can download + Luminotes Desktop or upgrade to new versions as they are released. + """, + ), + P( + u""" + If you have any questions about Luminotes Desktop or your purchase, please + """, + A( u"contact support", href = u"/contact_info", target = "_top" ), + u""" + for assistance. + """, + ), + )