diff --git a/NEWS b/NEWS index 3694d13..0fe3751 100644 --- a/NEWS +++ b/NEWS @@ -1,3 +1,6 @@ +1.2.14: March ??, 2008 + * Added ability to reorder notebooks on the right side of the page. + 1.2.13: March 11, 2008 * When the "all notes" note is the only note open, it now actually hides when the "hide" button is clicked. diff --git a/controller/Notebooks.py b/controller/Notebooks.py index f6bdcf1..00d6ffb 100644 --- a/controller/Notebooks.py +++ b/controller/Notebooks.py @@ -505,7 +505,7 @@ class Notebooks( object ): note.startup = startup if startup: if note.rank is None: - note.rank = self.__database.select_one( float, notebook.sql_highest_rank() ) + 1 + note.rank = self.__database.select_one( float, notebook.sql_highest_note_rank() ) + 1 else: note.rank = None note.user_id = user.object_id @@ -538,7 +538,7 @@ class Notebooks( object ): # otherwise, create a new note else: if startup: - rank = self.__database.select_one( float, notebook.sql_highest_rank() ) + 1 + rank = self.__database.select_one( float, notebook.sql_highest_note_rank() ) + 1 else: rank = None @@ -875,7 +875,8 @@ class Notebooks( object ): self.__database.save( notebook, commit = False ) # record the fact that the user has access to their new notebook - self.__database.execute( user.sql_save_notebook( notebook_id, read_write = True, owner = True ), commit = False ) + rank = self.__database.select_one( float, user.sql_highest_notebook_rank() ) + 1 + self.__database.execute( user.sql_save_notebook( notebook_id, read_write = True, owner = True, rank = rank ), commit = False ) self.__database.execute( user.sql_save_notebook( trash_id, read_write = True, owner = True ), commit = False ) if commit: @@ -1071,6 +1072,149 @@ class Notebooks( object ): redirect = u"/notebooks/%s" % notebook.object_id, ) + @expose( view = Json ) + @grab_user_id + @validate( + notebook_id = Valid_id(), + user_id = Valid_id( none_okay = True ), + ) + def move_up( self, notebook_id, user_id ): + """ + Reorder the user's notebooks by moving the given notebook up by one. If the notebook is already + first, then wrap it around to be the last notebook. + + @type notebook_id: unicode + @param notebook_id: id of notebook to move up + @type user_id: unicode or NoneType + @param user_id: id of current logged-in user (if any) + @rtype json dict + @return {} + @raise Access_error: the current user doesn't have access to the given notebook + @raise Validation_error: one of the arguments is invalid + """ + if not self.__users.check_access( user_id, notebook_id ): + raise Access_error() + + user = self.__database.load( User, user_id ) + if not user: + raise Access_error() + + # load the notebooks to which this user has access + notebooks = self.__database.select_many( + Notebook, + user.sql_load_notebooks( parents_only = True, undeleted_only = True ), + ) + if not notebooks: + raise Access_error() + + # find the given notebook and the one previous to it + previous_notebook = None + current_notebook = None + + for notebook in notebooks: + if notebook.object_id == notebook_id: + current_notebook = notebook + break + previous_notebook = notebook + + if current_notebook is None: + raise Access_error() + + # if there is no previous notebook, then the current notebook is first. so, move it after the + # last notebook + if previous_notebook is None: + last_notebook = notebooks[ -1 ] + self.__database.execute( + user.sql_update_notebook_rank( current_notebook.object_id, last_notebook.rank + 1 ), + commit = False, + ) + # otherwise, save the current and previous notebooks back to the database with swapped ranks + else: + self.__database.execute( + user.sql_update_notebook_rank( current_notebook.object_id, previous_notebook.rank ), + commit = False, + ) + self.__database.execute( + user.sql_update_notebook_rank( previous_notebook.object_id, current_notebook.rank ), + commit = False, + ) + + self.__database.commit() + + return dict() + + @expose( view = Json ) + @grab_user_id + @validate( + notebook_id = Valid_id(), + user_id = Valid_id( none_okay = True ), + ) + def move_down( self, notebook_id, user_id ): + """ + Reorder the user's notebooks by moving the given notebook down by one. If the notebook is + already last, then wrap it around to be the first notebook. + + @type notebook_id: unicode + @param notebook_id: id of notebook to move down + @type user_id: unicode or NoneType + @param user_id: id of current logged-in user (if any) + @rtype json dict + @return {} + @raise Access_error: the current user doesn't have access to the given notebook + @raise Validation_error: one of the arguments is invalid + """ + if not self.__users.check_access( user_id, notebook_id ): + raise Access_error() + + user = self.__database.load( User, user_id ) + if not user: + raise Access_error() + + # load the notebooks to which this user has access + notebooks = self.__database.select_many( + Notebook, + user.sql_load_notebooks( parents_only = True, undeleted_only = True ), + ) + if not notebooks: + raise Access_error() + + # find the given notebook and the one after it + current_notebook = None + next_notebook = None + + for notebook in notebooks: + if notebook.object_id == notebook_id: + current_notebook = notebook + elif current_notebook: + next_notebook = notebook + break + + if current_notebook is None: + raise Access_error() + + # if there is no next notebook, then the current notebook is last. so, move it before the + # first notebook + if next_notebook is None: + first_notebook = notebooks[ 0 ] + self.__database.execute( + user.sql_update_notebook_rank( current_notebook.object_id, first_notebook.rank - 1 ), + commit = False, + ) + # otherwise, save the current and next notebooks back to the database with swapped ranks + else: + self.__database.execute( + user.sql_update_notebook_rank( current_notebook.object_id, next_notebook.rank ), + commit = False, + ) + self.__database.execute( + user.sql_update_notebook_rank( next_notebook.object_id, current_notebook.rank ), + commit = False, + ) + + self.__database.commit() + + return dict() + def load_recent_notes( self, notebook_id, start = 0, count = 10, user_id = None ): """ Provide the information necessary to display the page for a particular notebook's most recent diff --git a/controller/Users.py b/controller/Users.py index a360dbc..9444604 100644 --- a/controller/Users.py +++ b/controller/Users.py @@ -262,7 +262,7 @@ class Users( object ): self.__database.save( user, commit = False ) # record the fact that the new user has access to their new notebook - self.__database.execute( user.sql_save_notebook( notebook_id, read_write = True, owner = True ), commit = False ) + self.__database.execute( user.sql_save_notebook( notebook_id, read_write = True, owner = True, rank = 0 ), commit = False ) self.__database.execute( user.sql_save_notebook( trash_id, read_write = True, owner = True ), commit = False ) self.__database.commit() @@ -335,7 +335,7 @@ class Users( object ): self.__database.save( user, commit = False ) # record the fact that the new user has access to their new notebook - self.__database.execute( user.sql_save_notebook( notebook_id, read_write = True, owner = True ), commit = False ) + self.__database.execute( user.sql_save_notebook( notebook_id, read_write = True, owner = True, rank = 0 ), commit = False ) self.__database.execute( user.sql_save_notebook( trash_id, read_write = True, owner = True ), commit = False ) self.__database.commit() @@ -966,7 +966,8 @@ class Users( object ): # if the user doesn't already have access to this notebook, then grant access if not self.__database.select_one( bool, user.sql_has_access( notebook.object_id ) ): - self.__database.execute( user.sql_save_notebook( notebook.object_id, invite.read_write, invite.owner ), commit = False ) + rank = self.__database.select_one( float, user.sql_highest_notebook_rank() ) + 1 + self.__database.execute( user.sql_save_notebook( notebook.object_id, invite.read_write, invite.owner, rank = rank ), commit = False ) # the same goes for the trash notebook if not self.__database.select_one( bool, user.sql_has_access( notebook.trash_id ) ): diff --git a/controller/test/Test_controller.py b/controller/test/Test_controller.py index ec41ed7..c457acc 100644 --- a/controller/test/Test_controller.py +++ b/controller/test/Test_controller.py @@ -41,14 +41,14 @@ class Test_controller( object ): # SQL-returning methods in User, Note, and Notebook to return functions that manipulate data in # Stub_database directly instead. This is all a little fragile, but it's better than relying on # the presence of a real database for unit tests. - def sql_save_notebook( self, notebook_id, read_write, owner, database ): + def sql_save_notebook( self, notebook_id, read_write, owner, rank, database ): if self.object_id in database.user_notebook: - database.user_notebook[ self.object_id ].append( ( notebook_id, read_write, owner ) ) + database.user_notebook[ self.object_id ].append( ( notebook_id, read_write, owner, rank ) ) else: - database.user_notebook[ self.object_id ] = [ ( notebook_id, read_write, owner ) ] + database.user_notebook[ self.object_id ] = [ ( notebook_id, read_write, owner, rank ) ] - User.sql_save_notebook = lambda self, notebook_id, read_write = False, owner = False: \ - lambda database: sql_save_notebook( self, notebook_id, read_write, owner, database ) + User.sql_save_notebook = lambda self, notebook_id, read_write = False, owner = False, rank = None: \ + lambda database: sql_save_notebook( self, notebook_id, read_write, owner, rank, database ) def sql_remove_notebook( self, notebook_id, database ): if self.object_id in database.user_notebook: @@ -66,10 +66,11 @@ class Test_controller( object ): if not notebook_infos: return [] for notebook_info in notebook_infos: - ( notebook_id, notebook_read_write, owner ) = notebook_info + ( notebook_id, notebook_read_write, owner, rank ) = notebook_info notebook = database.objects.get( notebook_id )[ -1 ] notebook.read_write = notebook_read_write notebook.owner = owner + notebook.rank = rank if parents_only and notebook.trash_id is None: continue if undeleted_only and notebook.deleted is True: @@ -118,7 +119,7 @@ class Test_controller( object ): def sql_has_access( self, notebook_id, read_write, owner, database ): for ( user_id, notebook_infos ) in database.user_notebook.items(): for notebook_info in notebook_infos: - ( db_notebook_id, db_read_write, db_owner ) = notebook_info + ( db_notebook_id, db_read_write, db_owner, rank ) = notebook_info if self.object_id == user_id and notebook_id == db_notebook_id: if read_write is True and db_read_write is False: @@ -135,23 +136,53 @@ class Test_controller( object ): def sql_update_access( self, notebook_id, read_write, owner, database ): for ( user_id, notebook_infos ) in database.user_notebook.items(): for notebook_info in notebook_infos: - ( db_notebook_id, db_read_write, db_owner ) = notebook_info + ( db_notebook_id, db_read_write, db_owner, rank ) = notebook_info if self.object_id == user_id and notebook_id == db_notebook_id: notebook_infos_copy = list( notebook_infos ) notebook_infos_copy.remove( notebook_info ) - notebook_infos_copy.append( ( notebook_id, read_write, owner ) ) + notebook_infos_copy.append( ( notebook_id, read_write, owner, rank ) ) database.user_notebook[ user_id ] = notebook_infos_copy User.sql_update_access = lambda self, notebook_id, read_write = False, owner = False: \ lambda database: sql_update_access( self, notebook_id, read_write, owner, database ) + def sql_update_notebook_rank( self, notebook_id, rank, database ): + max_rank = -1 + + for ( user_id, notebook_infos ) in database.user_notebook.items(): + for notebook_info in notebook_infos: + ( db_notebook_id, db_read_write, db_owner, db_rank ) = notebook_info + + if self.object_id == user_id and notebook_id == db_notebook_id: + notebook_infos_copy = list( notebook_infos ) + notebook_infos_copy.remove( notebook_info ) + notebook_infos_copy.append( ( db_notebook_id, db_read_write, db_owner, rank ) ) + database.user_notebook[ user_id ] = notebook_infos_copy + + User.sql_update_notebook_rank = lambda self, notebook_id, rank: \ + lambda database: sql_update_notebook_rank( self, notebook_id, rank, database ) + + def sql_highest_notebook_rank( self, database ): + max_rank = -1 + + for ( user_id, notebook_infos ) in database.user_notebook.items(): + for notebook_info in notebook_infos: + ( db_notebook_id, db_read_write, db_owner, db_rank ) = notebook_info + if self.object_id == user_id and db_rank > max_rank: + max_rank = db_rank + + return max_rank + + User.sql_highest_notebook_rank = lambda self: \ + lambda database: sql_highest_notebook_rank( self, database ) + def sql_revoke_invite_access( notebook_id, trash_id, email_address, database ): invites = [] for ( user_id, notebook_infos ) in database.user_notebook.items(): for notebook_info in list( notebook_infos ): - ( db_notebook_id, read_write, owner ) = notebook_info + ( db_notebook_id, read_write, owner, rank ) = notebook_info if db_notebook_id not in ( notebook_id, trash_id ): continue for ( object_id, obj_list ) in database.objects.items(): obj = obj_list[ -1 ] @@ -255,7 +286,7 @@ class Test_controller( object ): Notebook.sql_search_notes = lambda self, search_text: \ lambda database: sql_search_notes( self, search_text, database ) - def sql_highest_rank( self, database ): + def sql_highest_note_rank( self, database ): max_rank = -1 for ( object_id, obj_list ) in database.objects.items(): @@ -265,8 +296,8 @@ class Test_controller( object ): return max_rank - Notebook.sql_highest_rank = lambda self: \ - lambda database: sql_highest_rank( self, database ) + Notebook.sql_highest_note_rank = lambda self: \ + lambda database: sql_highest_note_rank( self, database ) def sql_count_notes( self, database ): count = 0 diff --git a/controller/test/Test_notebooks.py b/controller/test/Test_notebooks.py index adc2237..485c682 100644 --- a/controller/test/Test_notebooks.py +++ b/controller/test/Test_notebooks.py @@ -54,12 +54,12 @@ class Test_notebooks( Test_controller ): self.anon_notebook = Notebook.create( self.database.next_id( Notebook ), u"anon_notebook", user_id = user_id ) self.database.save( self.anon_notebook, commit = False ) - self.database.execute( self.user.sql_save_notebook( self.notebook.object_id, read_write = True, owner = True ) ) - self.database.execute( self.user.sql_save_notebook( self.notebook.trash_id, read_write = True, owner = True ) ) + self.database.execute( self.user.sql_save_notebook( self.notebook.object_id, read_write = True, owner = True, rank = 0 ) ) + self.database.execute( self.user.sql_save_notebook( self.notebook.trash_id, read_write = True, owner = True, rank = 0 ) ) self.database.execute( self.user.sql_save_notebook( self.anon_notebook.object_id, read_write = False, owner = False ) ) - self.database.execute( self.user2.sql_save_notebook( self.notebook.object_id, read_write = True, owner = False ) ) - self.database.execute( self.user2.sql_save_notebook( self.notebook.trash_id, read_write = True, owner = False ) ) + self.database.execute( self.user2.sql_save_notebook( self.notebook.object_id, read_write = True, owner = False, rank = 0 ) ) + self.database.execute( self.user2.sql_save_notebook( self.notebook.trash_id, read_write = True, owner = False, rank = 0 ) ) def make_users( self ): self.user = User.create( self.database.next_id( User ), self.username, self.password, self.email_address ) @@ -2299,6 +2299,11 @@ class Test_notebooks( Test_controller ): assert notebook.owner == True assert notebook.trash_id + self.user.sql_load_notebooks() + notebooks = self.database.select_many( Notebook, self.user.sql_load_notebooks() ) + new_notebook = [ notebook for notebook in notebooks if notebook.object_id == new_notebook_id ][ 0 ] + assert new_notebook.rank == 1 + def test_contents_after_create( self ): self.login() @@ -2423,8 +2428,8 @@ class Test_notebooks( Test_controller ): self.database.save( trash, commit = False ) notebook = Notebook.create( self.database.next_id( Notebook ), u"notebook", trash.object_id ) self.database.save( notebook, commit = False ) - self.database.execute( self.user.sql_save_notebook( notebook.object_id, read_write = True, owner = True ) ) - self.database.execute( self.user.sql_save_notebook( notebook.trash_id, read_write = True, owner = True ) ) + self.database.execute( self.user.sql_save_notebook( notebook.object_id, read_write = True, owner = True, rank = 1 ) ) + self.database.execute( self.user.sql_save_notebook( notebook.trash_id, read_write = True, owner = True, rank = 1 ) ) self.database.commit() self.login() @@ -2447,8 +2452,8 @@ class Test_notebooks( Test_controller ): self.database.save( trash, commit = False ) notebook = Notebook.create( self.database.next_id( Notebook ), u"notebook", trash.object_id ) self.database.save( notebook, commit = False ) - self.database.execute( self.user.sql_save_notebook( notebook.object_id, read_write = False, owner = False ) ) - self.database.execute( self.user.sql_save_notebook( notebook.trash_id, read_write = False, owner = False ) ) + self.database.execute( self.user.sql_save_notebook( notebook.object_id, read_write = False, owner = False, rank = 1 ) ) + self.database.execute( self.user.sql_save_notebook( notebook.trash_id, read_write = False, owner = False, rank = 1 ) ) self.database.commit() self.login() diff --git a/controller/test/Test_users.py b/controller/test/Test_users.py index 684e80e..044ec17 100644 --- a/controller/test/Test_users.py +++ b/controller/test/Test_users.py @@ -68,9 +68,9 @@ class Test_users( Test_controller ): self.user = User.create( self.database.next_id( User ), self.username, self.password, self.email_address ) self.database.save( self.user, commit = False ) - self.database.execute( self.user.sql_save_notebook( notebook_id1, read_write = True, owner = True ), commit = False ) + self.database.execute( self.user.sql_save_notebook( notebook_id1, read_write = True, owner = True, rank = 0 ), commit = False ) self.database.execute( self.user.sql_save_notebook( trash_id1, read_write = True, owner = True ), commit = False ) - self.database.execute( self.user.sql_save_notebook( notebook_id2, read_write = True, owner = True ), commit = False ) + self.database.execute( self.user.sql_save_notebook( notebook_id2, read_write = True, owner = True, rank = 1 ), commit = False ) self.database.execute( self.user.sql_save_notebook( trash_id2, read_write = True, owner = True ), commit = False ) self.user2 = User.create( self.database.next_id( User ), self.username2, self.password2, self.email_address2 ) @@ -143,6 +143,7 @@ class Test_users( Test_controller ): assert notebook.trash_id assert notebook.read_write == True assert notebook.owner == True + assert notebook.rank == 0 notebook = notebooks[ 1 ] assert notebook.object_id == notebooks[ 0 ].trash_id @@ -151,6 +152,7 @@ class Test_users( Test_controller ): assert notebook.trash_id == None assert notebook.read_write == True assert notebook.owner == True + assert notebook.rank == None notebook = notebooks[ 2 ] assert notebook.object_id == self.anon_notebook.object_id @@ -159,6 +161,7 @@ class Test_users( Test_controller ): assert notebook.trash_id == None assert notebook.read_write == False assert notebook.owner == False + assert notebook.rank == None assert result.get( u"login_url" ) is None assert result[ u"logout_url" ] == self.settings[ u"global" ][ u"luminotes.https_url" ] + u"/users/logout" @@ -222,6 +225,7 @@ class Test_users( Test_controller ): assert notebook.trash_id assert notebook.read_write == False assert notebook.owner == False + assert notebook.rank == 1 notebook = notebooks.get( self.notebooks[ 0 ].trash_id ) assert notebook.revision @@ -229,6 +233,7 @@ class Test_users( Test_controller ): assert notebook.trash_id == None assert notebook.read_write == False assert notebook.owner == False + assert notebook.rank == None notebook = notebooks.get( self.anon_notebook.object_id ) assert notebook.revision == self.anon_notebook.revision @@ -236,6 +241,7 @@ class Test_users( Test_controller ): assert notebook.trash_id == None assert notebook.read_write == False assert notebook.owner == False + assert notebook.rank == None assert result.get( u"login_url" ) is None assert result[ u"logout_url" ] == self.settings[ u"global" ][ u"luminotes.https_url" ] + u"/users/logout" @@ -283,6 +289,7 @@ class Test_users( Test_controller ): assert notebook.trash_id assert notebook.read_write == True assert notebook.owner == True + assert notebook.rank == 0 notebook = notebooks[ 1 ] assert notebook.object_id == notebooks[ 0 ].trash_id @@ -291,6 +298,7 @@ class Test_users( Test_controller ): assert notebook.trash_id == None assert notebook.read_write == True assert notebook.owner == True + assert notebook.rank == None notebook = notebooks[ 2 ] assert notebook.object_id == self.anon_notebook.object_id @@ -299,6 +307,7 @@ class Test_users( Test_controller ): assert notebook.trash_id == None assert notebook.read_write == False assert notebook.owner == False + assert notebook.rank == None assert result.get( u"login_url" ) is None assert result[ u"logout_url" ] == self.settings[ u"global" ][ u"luminotes.https_url" ] + u"/users/logout" @@ -379,22 +388,27 @@ class Test_users( Test_controller ): assert result[ u"notebooks" ][ 0 ].name == self.notebooks[ 0 ].name assert result[ u"notebooks" ][ 0 ].read_write == True assert result[ u"notebooks" ][ 0 ].owner == True + assert result[ u"notebooks" ][ 0 ].rank == 0 assert result[ u"notebooks" ][ 1 ].object_id assert result[ u"notebooks" ][ 1 ].name == u"trash" assert result[ u"notebooks" ][ 1 ].read_write == True assert result[ u"notebooks" ][ 1 ].owner == True + assert result[ u"notebooks" ][ 1 ].rank == None assert result[ u"notebooks" ][ 2 ].object_id == self.notebooks[ 1 ].object_id assert result[ u"notebooks" ][ 2 ].name == self.notebooks[ 1 ].name assert result[ u"notebooks" ][ 2 ].read_write == True assert result[ u"notebooks" ][ 2 ].owner == True + assert result[ u"notebooks" ][ 2 ].rank == 1 assert result[ u"notebooks" ][ 3 ].object_id assert result[ u"notebooks" ][ 3 ].name == u"trash" assert result[ u"notebooks" ][ 3 ].read_write == True assert result[ u"notebooks" ][ 3 ].owner == True + assert result[ u"notebooks" ][ 3 ].rank == None assert result[ u"notebooks" ][ 4 ].object_id == self.anon_notebook.object_id assert result[ u"notebooks" ][ 4 ].name == self.anon_notebook.name assert result[ u"notebooks" ][ 4 ].read_write == False assert result[ u"notebooks" ][ 4 ].owner == False + assert result[ u"notebooks" ][ 4 ].rank == None assert result[ u"login_url" ] is None assert result[ u"logout_url" ] == self.settings[ u"global" ][ u"luminotes.https_url" ] + u"/users/logout" @@ -412,6 +426,7 @@ class Test_users( Test_controller ): assert result[ u"notebooks" ][ 0 ].name == self.anon_notebook.name assert result[ u"notebooks" ][ 0 ].read_write == False assert result[ u"notebooks" ][ 0 ].owner == False + assert result[ u"notebooks" ][ 0 ].rank == None login_note = self.database.select_one( Note, self.anon_notebook.sql_load_note_by_title( u"login" ) ) assert result[ u"login_url" ] == u"%s/notebooks/%s?note_id=%s" % ( @@ -426,7 +441,7 @@ class Test_users( Test_controller ): assert rate_plan[ u"name" ] == u"super" assert rate_plan[ u"storage_quota_bytes" ] == 1337 * 10 - def test_current_after_login_with_invite_id( self ): + def test_login_with_invite_id( self ): # trick send_invites() into using a fake SMTP server Stub_smtp.reset() smtplib.SMTP = Stub_smtp @@ -461,7 +476,7 @@ class Test_users( Test_controller ): 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_current_after_login_with_after_login( self ): + def test_login_with_after_login( self ): after_login = u"/foo/bar" result = self.http_post( "/users/login", dict( @@ -473,7 +488,7 @@ class Test_users( Test_controller ): assert result[ u"redirect" ] == after_login - def test_current_after_login_with_after_login_with_full_url( self ): + def test_login_with_after_login_with_full_url( self ): after_login = u"http://this_url/does/not/start/with/a/slash" result = self.http_post( "/users/login", dict( @@ -601,6 +616,7 @@ class Test_users( Test_controller ): assert result[ u"notebooks" ][ 0 ].name == self.anon_notebook.name assert result[ u"notebooks" ][ 0 ].read_write == False assert result[ u"notebooks" ][ 0 ].owner == False + assert result[ u"notebooks" ][ 0 ].rank == None login_note = self.database.select_one( Note, self.anon_notebook.sql_load_note_by_title( u"login" ) ) assert result[ u"login_url" ] == u"%s/notebooks/%s?note_id=%s" % ( @@ -2104,6 +2120,9 @@ class Test_users( Test_controller ): assert result[ u"error" ] def test_convert_invite_to_access( self ): + # start the invitee out with access to one notebook + self.database.execute( self.user2.sql_save_notebook( self.notebooks[ 1 ].object_id, read_write = True, owner = False, rank = 7 ), commit = False ) + # trick send_invites() into using a fake SMTP server Stub_smtp.reset() smtplib.SMTP = Stub_smtp @@ -2144,6 +2163,12 @@ class Test_users( Test_controller ): ) ) assert access is True + self.user.sql_load_notebooks() + notebooks = self.database.select_many( Notebook, self.user2.sql_load_notebooks() ) + new_notebook = [ notebook for notebook in notebooks if notebook.object_id == invite.notebook_id ][ 0 ] + print new_notebook.rank + assert new_notebook.rank == 8 # one higher than the other notebook this user has access to + assert invite.redeemed_user_id == self.user2.object_id def test_convert_invite_to_access_same_user( self ): @@ -3245,6 +3270,7 @@ class Test_users( Test_controller ): assert result[ u"notebooks" ][ 0 ].name == self.notebooks[ 0 ].name assert result[ u"notebooks" ][ 0 ].read_write == True assert result[ u"notebooks" ][ 0 ].owner == True + assert result[ u"notebooks" ][ 0 ].rank == 0 assert result[ u"login_url" ] == None assert result[ u"logout_url" ] == self.settings[ u"global" ][ u"luminotes.https_url" ] + u"/users/logout" @@ -3284,6 +3310,7 @@ class Test_users( Test_controller ): assert result[ u"notebooks" ][ 0 ].name == self.notebooks[ 0 ].name assert result[ u"notebooks" ][ 0 ].read_write == True assert result[ u"notebooks" ][ 0 ].owner == True + assert result[ u"notebooks" ][ 0 ].rank == 0 assert result[ u"login_url" ] == None assert result[ u"logout_url" ] == self.settings[ u"global" ][ u"luminotes.https_url" ] + u"/users/logout" @@ -3322,6 +3349,7 @@ class Test_users( Test_controller ): assert result[ u"notebooks" ][ 0 ].name == self.notebooks[ 0 ].name assert result[ u"notebooks" ][ 0 ].read_write == True assert result[ u"notebooks" ][ 0 ].owner == True + assert result[ u"notebooks" ][ 0 ].rank == 0 assert result[ u"login_url" ] == None assert result[ u"logout_url" ] == self.settings[ u"global" ][ u"luminotes.https_url" ] + u"/users/logout" @@ -3360,6 +3388,7 @@ class Test_users( Test_controller ): assert result[ u"notebooks" ][ 0 ].name == self.notebooks[ 0 ].name assert result[ u"notebooks" ][ 0 ].read_write == True assert result[ u"notebooks" ][ 0 ].owner == True + assert result[ u"notebooks" ][ 0 ].rank == 0 assert result[ u"login_url" ] == None assert result[ u"logout_url" ] == self.settings[ u"global" ][ u"luminotes.https_url" ] + u"/users/logout" @@ -3396,6 +3425,7 @@ class Test_users( Test_controller ): assert result[ u"notebooks" ][ 0 ].name == self.notebooks[ 0 ].name assert result[ u"notebooks" ][ 0 ].read_write == True assert result[ u"notebooks" ][ 0 ].owner == True + assert result[ u"notebooks" ][ 0 ].rank == 0 assert result[ u"login_url" ] == None assert result[ u"logout_url" ] == self.settings[ u"global" ][ u"luminotes.https_url" ] + u"/users/logout" diff --git a/model/Notebook.py b/model/Notebook.py index 4f95fe5..cd9c99a 100644 --- a/model/Notebook.py +++ b/model/Notebook.py @@ -12,7 +12,8 @@ class Notebook( Persistent ): WHITESPACE_PATTERN = re.compile( r"\s+" ) SEARCH_OPERATORS = re.compile( r"[&|!()'\\:]" ) - def __init__( self, object_id, revision = None, name = None, trash_id = None, deleted = False, user_id = None, read_write = True, owner = True ): + def __init__( self, object_id, revision = None, name = None, trash_id = None, deleted = False, + user_id = None, read_write = True, owner = True, rank = None ): """ Create a new notebook with the given id and name. @@ -32,6 +33,8 @@ class Notebook( Persistent ): @param read_write: whether this view of the notebook is currently read-write (optional, defaults to True) @type owner: bool or NoneType @param owner: whether this view of the notebook currently has owner-level access (optional, defaults to True) + @type rank: float or NoneType + @param rank: indicates numeric ordering of this note in relation to other notebooks @rtype: Notebook @return: newly constructed notebook """ @@ -42,9 +45,10 @@ class Notebook( Persistent ): self.__user_id = user_id self.__read_write = read_write self.__owner = owner + self.__rank = rank @staticmethod - def create( object_id, name = None, trash_id = None, deleted = False, user_id = None, read_write = True, owner = True ): + def create( object_id, name = None, trash_id = None, deleted = False, user_id = None, read_write = True, owner = True, rank = None ): """ Convenience constructor for creating a new notebook. @@ -62,10 +66,12 @@ class Notebook( Persistent ): @param read_write: whether this view of the notebook is currently read-write (optional, defaults to True) @type owner: bool or NoneType @param owner: whether this view of the notebook currently has owner-level access (optional, defaults to True) + @type rank: float or NoneType + @param rank: indicates numeric ordering of this note in relation to other notebooks @rtype: Notebook @return: newly constructed notebook """ - return Notebook( object_id, name = name, trash_id = trash_id, user_id = user_id, read_write = read_write, owner = owner ) + return Notebook( object_id, name = name, trash_id = trash_id, user_id = user_id, read_write = read_write, owner = owner, rank = rank ) @staticmethod def sql_load( object_id, revision = None ): @@ -182,7 +188,7 @@ class Notebook( Persistent ): ) as sub; """ % ( quote( search_text ), quote( self.object_id ) ) - def sql_highest_rank( self ): + def sql_highest_note_rank( self ): """ Return a SQL string to determine the highest numbered rank of all notes in this notebook." """ @@ -232,9 +238,15 @@ class Notebook( Persistent ): self.__user_id = user_id self.update_revision() + def __set_rank( self, rank ): + # The rank member isn't actually saved to the database, so setting it doesn't need to + # call update_revision(). + self.__rank = rank + name = property( lambda self: self.__name, __set_name ) trash_id = property( lambda self: self.__trash_id ) read_write = property( lambda self: self.__read_write, __set_read_write ) owner = property( lambda self: self.__owner, __set_owner ) deleted = property( lambda self: self.__deleted, __set_deleted ) user_id = property( lambda self: self.__user_id, __set_user_id ) + rank = property( lambda self: self.__rank, __set_rank ) diff --git a/model/User.py b/model/User.py index 4c87ac5..082c621 100644 --- a/model/User.py +++ b/model/User.py @@ -149,18 +149,27 @@ class User( Persistent ): read_write_clause = "" return \ - "select notebook_current.*, user_notebook.read_write, user_notebook.owner from user_notebook, notebook_current " + \ - "where user_notebook.user_id = %s%s%s%s and user_notebook.notebook_id = notebook_current.id order by revision;" % \ - ( quote( self.object_id ), parents_only_clause, undeleted_only_clause, read_write_clause ) + """ + select + notebook_current.*, user_notebook.read_write, user_notebook.owner, user_notebook.rank + from + user_notebook, notebook_current + where + user_notebook.user_id = %s%s%s%s and + user_notebook.notebook_id = notebook_current.id + order by user_notebook.rank; + """ % ( quote( self.object_id ), parents_only_clause, undeleted_only_clause, read_write_clause ) - def sql_save_notebook( self, notebook_id, read_write = True, owner = True ): + def sql_save_notebook( self, notebook_id, read_write = True, owner = True, rank = None ): """ Return a SQL string to save the id of a notebook to which this user has access. """ + if rank is None: rank = quote( None ) + return \ - "insert into user_notebook ( user_id, notebook_id, read_write, owner ) values " + \ + "insert into user_notebook ( user_id, notebook_id, read_write, owner, rank ) values " + \ "( %s, %s, %s, %s );" % ( quote( self.object_id ), quote( notebook_id ), quote( read_write and 't' or 'f' ), - quote( owner and 't' or 'f' ) ) + quote( owner and 't' or 'f' ), rank ) def sql_remove_notebook( self, notebook_id ): """ @@ -199,6 +208,20 @@ class User( Persistent ): ( quote( read_write and 't' or 'f' ), quote( owner and 't' or 'f' ), quote( self.object_id ), quote( notebook_id ) ) + def sql_update_notebook_rank( self, notebook_id, rank ): + """ + Return a SQL string to update the user's rank for the given notebook. + """ + return \ + "update user_notebook set rank = %s where user_id = %s and notebook_id = %s;" % \ + ( quote( rank ), quote( self.object_id ), quote( notebook_id ) ) + + def sql_highest_notebook_rank( self ): + """ + Return a SQL string to determine the highest numbered rank of all notebooks the user has access to." + """ + return "select coalesce( max( rank ), -1 ) from user_notebook where user_id = %s;" % quote( self.object_id ) + @staticmethod def sql_revoke_invite_access( notebook_id, trash_id, email_address ): return \ diff --git a/model/delta/1.2.14.sql b/model/delta/1.2.14.sql new file mode 100644 index 0000000..faf6771 --- /dev/null +++ b/model/delta/1.2.14.sql @@ -0,0 +1 @@ +alter table user_notebook add column rank numeric; diff --git a/model/schema.sql b/model/schema.sql index a6e2dc0..0dbf305 100644 --- a/model/schema.sql +++ b/model/schema.sql @@ -165,7 +165,8 @@ CREATE TABLE user_notebook ( user_id text NOT NULL, notebook_id text NOT NULL, read_write boolean DEFAULT false, - "owner" boolean DEFAULT false + "owner" boolean DEFAULT false, + rank numeric ); diff --git a/model/test/Test_notebook.py b/model/test/Test_notebook.py index e7a74a9..c506605 100644 --- a/model/test/Test_notebook.py +++ b/model/test/Test_notebook.py @@ -12,9 +12,12 @@ class Test_notebook( object ): self.trash_name = u"trash" self.user_id = u"me" self.delta = timedelta( seconds = 1 ) + self.read_write = True + self.owner = False + self.rank = 17.5 self.trash = Notebook.create( self.trash_id, self.trash_name, read_write = False, deleted = False, user_id = self.user_id ) - self.notebook = Notebook.create( self.object_id, self.name, trash_id = self.trash.object_id, deleted = False, user_id = self.user_id ) + self.notebook = Notebook.create( self.object_id, self.name, trash_id = self.trash.object_id, deleted = False, user_id = self.user_id, read_write = self.read_write, owner = self.owner, rank = self.rank ) self.note = Note.create( "19", u"

title

blah" ) def test_create( self ): @@ -25,6 +28,9 @@ class Test_notebook( object ): assert self.notebook.trash_id == self.trash_id assert self.notebook.deleted == False assert self.notebook.user_id == self.user_id + assert self.notebook.read_write == self.read_write + assert self.notebook.owner == self.owner + assert self.notebook.rank == self.rank assert self.trash.object_id == self.trash_id assert datetime.now( tz = utc ) - self.trash.revision < self.delta @@ -33,6 +39,9 @@ class Test_notebook( object ): assert self.trash.trash_id == None assert self.trash.deleted == False assert self.trash.user_id == self.user_id + assert self.trash.read_write == False + assert self.trash.owner == True + assert self.trash.rank == None def test_set_name( self ): new_name = u"my new notebook" @@ -63,6 +72,13 @@ class Test_notebook( object ): assert self.notebook.user_id == u"5" assert self.notebook.revision > previous_revision + def test_set_rank( self ): + original_revision = self.notebook.revision + self.notebook.rank = 17.7 + + assert self.notebook.rank == 17.7 + assert self.notebook.revision == original_revision + def test_to_dict( self ): d = self.notebook.to_dict() diff --git a/static/css/style.css b/static/css/style.css index 76edfa7..cc77771 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -221,6 +221,18 @@ img { background-image: url(/static/images/numbered_list_button_down.png); } +#current_notebook_up_hover_preload { + height: 0; + overflow: hidden; + background-image: url(/static/images/arrow_up_hover.png); +} + +#current_notebook_down_hover_preload { + height: 0; + overflow: hidden; + background-image: url(/static/images/arrow_down_hover.png); +} + #link_area { float: right; text-align: left; @@ -564,6 +576,16 @@ img { background: url(/static/images/current_notebook_inner_tl.png) no-repeat top left; } +#current_notebook_up { + cursor: pointer; + padding-top: 1px; +} + +#current_notebook_down { + cursor: pointer; + padding-top: 1px; +} + .trash_notebook_color { background-color: #d0d0d0; } diff --git a/static/js/Wiki.js b/static/js/Wiki.js index 9b9c3ed..7bea9a4 100644 --- a/static/js/Wiki.js +++ b/static/js/Wiki.js @@ -1,3 +1,5 @@ +IMAGE_DIR = "/static/images/"; + function Wiki( invoker ) { this.next_id = null; this.focused_editor = null; @@ -105,9 +107,28 @@ function Wiki( invoker ) { "href": "/notebooks/" + this.notebook.trash_id + "?parent_id=" + this.notebook.object_id }, "trash" ); var message_div = this.display_message( "The notebook has been moved to the", [ trash_link, ". ", undo_button ], "notes_top" ); - var self = this; connect( undo_button, "onclick", function ( event ) { self.undelete_notebook( event, deleted_id ); } ); } + + var current_notebook_up = getElement( "current_notebook_up" ); + if ( current_notebook_up ) { + connect( current_notebook_up, "onmouseover", function ( event ) { current_notebook_up.src = IMAGE_DIR + "up_arrow_hover.png"; } ); + connect( current_notebook_up, "onmouseout", function ( event ) { current_notebook_up.src = IMAGE_DIR + "up_arrow.png"; } ); + connect( current_notebook_up, "onclick", function ( event ) { + current_notebook_up.src = IMAGE_DIR + "up_arrow.png"; + self.move_current_notebook_up( event ); + } ); + } + + var current_notebook_down = getElement( "current_notebook_down" ); + if ( current_notebook_down ) { + connect( current_notebook_down, "onmouseover", function ( event ) { current_notebook_down.src = IMAGE_DIR + "down_arrow_hover.png"; } ); + connect( current_notebook_down, "onmouseout", function ( event ) { current_notebook_down.src = IMAGE_DIR + "down_arrow.png"; } ); + connect( current_notebook_down, "onclick", function ( event ) { + current_notebook_down.src = IMAGE_DIR + "down_arrow.png"; + self.move_current_notebook_down( event ); + } ); + } } Wiki.prototype.update_next_id = function ( result ) { @@ -851,8 +872,6 @@ Wiki.prototype.editor_key_pressed = function ( editor, event ) { } } -IMAGE_DIR = "/static/images/"; - Wiki.prototype.make_image_button = function ( name, filename_prefix, handle_mouse_up_and_down ) { var button = getElement( name ); @@ -1530,6 +1549,56 @@ Wiki.prototype.display_invites = function ( invite_area ) { replaceChildNodes( invite_area, div ); } +Wiki.prototype.move_current_notebook_up = function ( event ) { + var current_notebook = getElement( "current_notebook_wrapper" ); + var sibling_notebook = current_notebook; + + // find the previous sibling notebook node + do { + var sibling_notebook = sibling_notebook.previousSibling; + } while ( sibling_notebook && sibling_notebook.className != "link_area_item" ); + + removeElement( current_notebook ); + if ( sibling_notebook ) + // move the current notebook up before the previous notebook node + insertSiblingNodesBefore( sibling_notebook, current_notebook ); + // if the current notebook is the first one, wrap it around to the bottom of the list + else { + var notebooks_area = getElement( "notebooks_area" ); + appendChildNodes( notebooks_area, current_notebook ); + } + + var self = this; + this.invoker.invoke( "/notebooks/move_up", "POST", { + "notebook_id": this.notebook_id + } ); +} + +Wiki.prototype.move_current_notebook_down = function ( event ) { + var current_notebook = getElement( "current_notebook_wrapper" ); + var sibling_notebook = current_notebook; + + // find the next sibling notebook node + do { + var sibling_notebook = sibling_notebook.nextSibling; + } while ( sibling_notebook && sibling_notebook.className != "link_area_item" ); + + removeElement( current_notebook ); + if ( sibling_notebook ) + // move the current notebook down after the previous notebook node + insertSiblingNodesAfter( sibling_notebook, current_notebook ); + // if the current notebook is the last one, wrap it around to the top of the list + else { + var notebooks_area_title = getElement( "notebooks_area_title" ); + insertSiblingNodesAfter( notebooks_area_title, current_notebook ); + } + + var self = this; + this.invoker.invoke( "/notebooks/move_down", "POST", { + "notebook_id": this.notebook_id + } ); +} + Wiki.prototype.display_message = function ( text, nodes, position_after ) { this.clear_messages(); this.clear_pulldowns(); diff --git a/view/Link_area.py b/view/Link_area.py index 8db4632..0080687 100644 --- a/view/Link_area.py +++ b/view/Link_area.py @@ -1,4 +1,4 @@ -from Tags import Div, Span, H4, A, Strong +from Tags import Div, Span, H4, A, Strong, Img from Rounded_div import Rounded_div @@ -115,7 +115,7 @@ class Link_area( Div ): ), Div( - ( len( linked_notebooks ) > 0 ) and H4( u"notebooks" ) or None, + ( len( linked_notebooks ) > 0 ) and H4( u"notebooks", id = u"notebooks_area_title" ) or None, [ ( nb.object_id == notebook.object_id ) and Rounded_div( u"current_notebook", A( @@ -123,6 +123,12 @@ class Link_area( Div ): href = u"/notebooks/%s" % nb.object_id, id = u"notebook_%s" % nb.object_id, ), + ( len( linked_notebooks ) > 1 ) and Span( + Img( src = u"/static/images/up_arrow.png", width = u"20", height = u"17", id = u"current_notebook_up" ), + Img( src = u"/static/images/down_arrow.png", width = u"20", height = u"17", id = u"current_notebook_down" ), + Span( id = "current_notebook_up_hover_preload" ), + Span( id = "current_notebook_down_hover_preload" ), + ) or None, class_ = u"link_area_item", ) or Div( diff --git a/view/Rounded_div.py b/view/Rounded_div.py index fc6fff5..a7c1350 100644 --- a/view/Rounded_div.py +++ b/view/Rounded_div.py @@ -22,5 +22,6 @@ class Rounded_div( Div ): Div.__init__( self, div, + id = u"%s_wrapper" % image_name, class_ = u"%s_color" % image_name, )