diff --git a/UPGRADE b/UPGRADE new file mode 100644 index 0000000..97034d6 --- /dev/null +++ b/UPGRADE @@ -0,0 +1,12 @@ +To upgrade the Luminotes database from an earlier version, manually apply each +relevant schema delta file within model/delta/ + +For instance, if you were upgrading from version 1.0.1 to 1.0.4, you would +apply the following deltas in order: + + psql -U luminotes luminotes -f model/delta/1.0.2.sql + psql -U luminotes luminotes -f model/delta/1.0.3.sql + psql -U luminotes luminotes -f model/delta/1.0.4.sql + +Any version which does not introduce a schema change does not have a +corresponding schema delta file. diff --git a/controller/Notebooks.py b/controller/Notebooks.py index 333636b..6a3dc63 100644 --- a/controller/Notebooks.py +++ b/controller/Notebooks.py @@ -54,9 +54,11 @@ class Notebooks( object ): parent_id = Valid_id(), revision = Valid_revision(), rename = Valid_bool(), + deleted_id = Valid_id(), user_id = Valid_id( none_okay = True ), ) - def default( self, notebook_id, note_id = None, parent_id = None, revision = None, rename = False, user_id = None ): + def default( self, notebook_id, note_id = None, parent_id = None, revision = None, rename = False, + deleted_id = None, user_id = None ): """ Provide the information necessary to display the page for a particular notebook. If a particular note id is given without a revision, then the most recent version of that note is @@ -70,6 +72,10 @@ class Notebooks( object ): @param parent_id: id of parent notebook to this notebook (optional) @type revision: unicode or NoneType @param revision: revision timestamp of the provided note (optional) + @type rename: bool or NoneType + @param rename: whether this is a new notebook and should be renamed (optional, defaults to False) + @type deleted_id: unicode or NoneType + @param deleted_id: id of the notebook that was just deleted, if any (optional) @type user_id: unicode or NoneType @param user_id: id of current logged-in user (if any) @rtype: unicode @@ -89,6 +95,7 @@ class Notebooks( object ): else: result[ "conversion" ] = u"signup" result[ "rename" ] = rename + result[ "deleted_id" ] = deleted_id return result @@ -725,24 +732,30 @@ class Notebooks( object ): raise Access_error() user = self.__database.load( User, user_id ) + notebook = self.__create_notebook( u"new notebook", user ) + return dict( + redirect = u"/notebooks/%s?rename=true" % notebook.object_id, + ) + + def __create_notebook( self, name, user, commit = True ): # create the notebook along with a trash trash_id = self.__database.next_id( Notebook, commit = False ) trash = Notebook.create( trash_id, u"trash" ) self.__database.save( trash, commit = False ) notebook_id = self.__database.next_id( Notebook, commit = False ) - notebook = Notebook.create( notebook_id, u"new notebook", trash_id ) + notebook = Notebook.create( notebook_id, name, trash_id ) 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 ), commit = False ) self.__database.execute( user.sql_save_notebook( trash_id, read_write = True ), commit = False ) - self.__database.commit() - return dict( - redirect = u"/notebooks/%s?rename=true" % notebook_id, - ) + if commit: + self.__database.commit() + + return notebook @expose( view = Json ) @grab_user_id @@ -766,6 +779,7 @@ class Notebooks( object ): @raise Access_error: the current user doesn't have access to the given notebook @raise Validation_error: one of the arguments is invalid """ + user = self.__database.load( User, user_id ) if not self.__users.check_access( user_id, notebook_id, read_write = True ): raise Access_error() @@ -792,6 +806,95 @@ class Notebooks( object ): return dict() + @expose( view = Json ) + @grab_user_id + @validate( + notebook_id = Valid_id(), + user_id = Valid_id( none_okay = True ), + ) + def delete( self, notebook_id, user_id ): + """ + Delete the given notebook and redirect to a remaining notebook. If there is none, create one. + + @type notebook_id: unicode + @param notebook_id: id of notebook to delete + @type user_id: unicode or NoneType + @param user_id: id of current logged-in user (if any) + @rtype dict + @return { "redirect": remainingnotebookurl } + @raise Access_error: the current user doesn't have access to the given notebook + @raise Validation_error: one of the arguments is invalid + """ + if user_id is None: + raise Access_error() + + user = self.__database.load( User, user_id ) + + if not self.__users.check_access( user_id, notebook_id, read_write = True ): + raise Access_error() + + notebook = self.__database.load( Notebook, notebook_id ) + + # TODO: maybe if notebook.deleted is already True, then the notebook should be "deleted forever" + if not notebook: + raise Access_error() + + # prevent deletion of a trash notebook directly + if notebook.name == u"trash": + raise Access_error() + + notebook.deleted = True + self.__database.save( notebook, commit = False ) + + # redirect to a remaining undeleted notebook, or if there isn't one, create an empty notebook + remaining_notebook = self.__database.select_one( Notebook, user.sql_load_notebooks( parents_only = True ) ) + if remaining_notebook is None: + remaining_notebook = self.__create_notebook( u"my notebook", user, commit = False ) + + self.__database.commit() + + return dict( + redirect = u"/notebooks/%s?deleted_id=%s" % ( remaining_notebook.object_id, notebook.object_id ), + ) + + @expose( view = Json ) + @grab_user_id + @validate( + notebook_id = Valid_id(), + user_id = Valid_id( none_okay = True ), + ) + def undelete( self, notebook_id, user_id ): + """ + Undelete the given notebook and redirect to it. + + @type notebook_id: unicode + @param notebook_id: id of notebook to undelete + @type user_id: unicode or NoneType + @param user_id: id of current logged-in user (if any) + @rtype dict + @return { "redirect": notebookurl } + @raise Access_error: the current user doesn't have access to the given notebook + @raise Validation_error: one of the arguments is invalid + """ + if user_id is None: + raise Access_error() + + if not self.__users.check_access( user_id, notebook_id, read_write = True ): + raise Access_error() + + notebook = self.__database.load( Notebook, notebook_id ) + + if not notebook: + raise Access_error() + + notebook.deleted = False + self.__database.save( notebook, commit = False ) + self.__database.commit() + + return dict( + redirect = u"/notebooks/%s" % notebook.object_id, + ) + 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/test/Test_controller.py b/controller/test/Test_controller.py index ed5d57a..de95a87 100644 --- a/controller/test/Test_controller.py +++ b/controller/test/Test_controller.py @@ -26,7 +26,7 @@ class Test_controller( object ): User.sql_save_notebook = lambda self, notebook_id, read_write = False: \ lambda database: sql_save_notebook( self, notebook_id, read_write, database ) - def sql_load_notebooks( self, parents_only, database ): + def sql_load_notebooks( self, parents_only, deleted, database ): notebooks = [] notebook_tuples = database.user_notebook.get( self.object_id ) @@ -38,12 +38,14 @@ class Test_controller( object ): notebook._Notebook__read_write = read_write if parents_only and notebook.trash_id is None: continue + if deleted != notebook.deleted: + continue notebooks.append( notebook ) return notebooks - User.sql_load_notebooks = lambda self, parents_only = False: \ - lambda database: sql_load_notebooks( self, parents_only, database ) + User.sql_load_notebooks = lambda self, parents_only = False, deleted = False: \ + lambda database: sql_load_notebooks( self, parents_only, deleted, database ) def sql_load_by_username( username, database ): users = [] diff --git a/controller/test/Test_notebooks.py b/controller/test/Test_notebooks.py index d431350..8666794 100644 --- a/controller/test/Test_notebooks.py +++ b/controller/test/Test_notebooks.py @@ -1760,6 +1760,159 @@ class Test_notebooks( Test_controller ): assert result[ u"error" ] + def test_delete( self ): + self.login() + + result = self.http_post( "/notebooks/delete", dict( + notebook_id = self.notebook.object_id, + ), session_id = self.session_id ) + + assert result[ u"redirect" ].startswith( u"/notebooks/" ) + + # assert that we're redirected to a newly created notebook + remaining_notebook_id = result[ u"redirect" ].split( u"/notebooks/" )[ -1 ].split( u"?" )[ 0 ] + notebook = self.database.last_saved_obj + + assert isinstance( notebook, Notebook ) + assert notebook.object_id == remaining_notebook_id + assert notebook.name == u"my notebook" + assert notebook.read_write == True + assert notebook.trash_id + + def test_delete_with_multiple_notebooks( self ): + # create a second notebook, which we should be redirected to after the first notebook is deleted + trash = Notebook.create( self.database.next_id( Notebook ), u"trash" ) + 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 ) ) + self.database.execute( self.user.sql_save_notebook( notebook.trash_id, read_write = True ) ) + self.database.commit() + + self.login() + + result = self.http_post( "/notebooks/delete", dict( + notebook_id = self.notebook.object_id, + ), session_id = self.session_id ) + + assert result[ u"redirect" ].startswith( u"/notebooks/" ) + + # assert that we're redirected to the second notebook + remaining_notebook_id = result[ u"redirect" ].split( u"/notebooks/" )[ -1 ].split( u"?" )[ 0 ] + assert remaining_notebook_id + assert remaining_notebook_id == notebook.object_id + + def test_contents_after_delete( self ): + self.login() + + result = self.http_post( "/notebooks/delete", dict( + notebook_id = self.notebook.object_id, + ), session_id = self.session_id ) + + result = cherrypy.root.notebooks.contents( + notebook_id = self.notebook.object_id, + user_id = self.user.object_id, + ) + + notebook = result[ "notebook" ] + assert notebook.deleted == True + + def test_delete_without_login( self ): + result = self.http_post( "/notebooks/delete", dict( + notebook_id = self.notebook.object_id, + ), session_id = self.session_id ) + + assert result[ u"error" ] + + def test_delete_trash( self ): + self.login() + + result = self.http_post( "/notebooks/delete", dict( + notebook_id = self.notebook.trash_id, + ), session_id = self.session_id ) + + assert u"error" in result + + def test_undelete( self ): + self.login() + + self.http_post( "/notebooks/delete", dict( + notebook_id = self.notebook.object_id, + ), session_id = self.session_id ) + + result = self.http_post( "/notebooks/undelete", dict( + notebook_id = self.notebook.object_id, + ), session_id = self.session_id ) + + assert result[ u"redirect" ].startswith( u"/notebooks/" ) + + # assert that we're redirected to the undeleted notebook + notebook_id = result[ u"redirect" ].split( u"/notebooks/" )[ -1 ] + notebook = self.database.last_saved_obj + + assert isinstance( notebook, Notebook ) + assert notebook.object_id == notebook_id + assert notebook.name == self.notebook.name + assert notebook.read_write == True + assert notebook.trash_id + + def test_contents_after_undelete( self ): + self.login() + + self.http_post( "/notebooks/delete", dict( + notebook_id = self.notebook.object_id, + ), session_id = self.session_id ) + + result = self.http_post( "/notebooks/undelete", dict( + notebook_id = self.notebook.object_id, + ), session_id = self.session_id ) + + result = cherrypy.root.notebooks.contents( + notebook_id = self.notebook.object_id, + user_id = self.user.object_id, + ) + + notebook = result[ "notebook" ] + assert notebook.deleted == False + + def test_undelete_without_login( self ): + self.http_post( "/notebooks/delete", dict( + notebook_id = self.notebook.object_id, + ), session_id = self.session_id ) + + result = self.http_post( "/notebooks/undelete", dict( + notebook_id = self.notebook.object_id, + ), session_id = self.session_id ) + + assert result[ u"error" ] + + def test_undelete_twice( self ): + self.login() + + self.http_post( "/notebooks/delete", dict( + notebook_id = self.notebook.object_id, + ), session_id = self.session_id ) + + self.http_post( "/notebooks/undelete", dict( + notebook_id = self.notebook.object_id, + ), session_id = self.session_id ) + + result = self.http_post( "/notebooks/undelete", dict( + notebook_id = self.notebook.object_id, + ), session_id = self.session_id ) + + assert result[ u"redirect" ].startswith( u"/notebooks/" ) + + # assert that we're redirected to the undeleted notebook + notebook_id = result[ u"redirect" ].split( u"/notebooks/" )[ -1 ] + notebook = self.database.last_saved_obj + + assert isinstance( notebook, Notebook ) + assert notebook.object_id == notebook_id + assert notebook.name == self.notebook.name + assert notebook.read_write == True + assert notebook.trash_id + def test_recent_notes( self ): result = cherrypy.root.notebooks.load_recent_notes( self.notebook.object_id, diff --git a/model/Notebook.py b/model/Notebook.py index 3f3d29d..9a95afe 100644 --- a/model/Notebook.py +++ b/model/Notebook.py @@ -12,7 +12,7 @@ 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, read_write = True ): + def __init__( self, object_id, revision = None, name = None, trash_id = None, deleted = False, read_write = True ): """ Create a new notebook with the given id and name. @@ -24,6 +24,8 @@ class Notebook( Persistent ): @param name: name of this notebook (optional) @type trash_id: Notebook or NoneType @param trash_id: id of the notebook where deleted notes from this notebook go to die (optional) + @type deleted: bool or NoneType + @param deleted: whether this notebook is currently deleted (optional, defaults to False) @type read_write: bool or NoneType @param read_write: whether this view of the notebook is currently read-write (optional, defaults to True) @rtype: Notebook @@ -32,10 +34,11 @@ class Notebook( Persistent ): Persistent.__init__( self, object_id, revision ) self.__name = name self.__trash_id = trash_id + self.__deleted = deleted self.__read_write = read_write @staticmethod - def create( object_id, name = None, trash_id = None, read_write = True ): + def create( object_id, name = None, trash_id = None, deleted = False, read_write = True ): """ Convenience constructor for creating a new notebook. @@ -45,6 +48,8 @@ class Notebook( Persistent ): @param name: name of this notebook (optional) @type trash_id: Notebook or NoneType @param trash_id: id of the notebook where deleted notes from this notebook go to die (optional) + @type deleted: bool or NoneType + @param deleted: whether this notebook is currently deleted (optional, defaults to False) @type read_write: bool or NoneType @param read_write: whether this view of the notebook is currently read-write (optional, defaults to True) @rtype: Notebook @@ -71,10 +76,10 @@ class Notebook( Persistent ): def sql_create( self ): return \ - "insert into notebook ( id, revision, name, trash_id ) " + \ - "values ( %s, %s, %s, %s );" % \ + "insert into notebook ( id, revision, name, trash_id, deleted ) " + \ + "values ( %s, %s, %s, %s, %s );" % \ ( quote( self.object_id ), quote( self.revision ), quote( self.__name ), - quote( self.__trash_id ) ) + quote( self.__trash_id ), quote( self.deleted ) ) def sql_update( self ): return self.sql_create() @@ -187,6 +192,7 @@ class Notebook( Persistent ): name = self.__name, trash_id = self.__trash_id, read_write = self.__read_write, + deleted = self.__deleted, ) ) return d @@ -196,8 +202,15 @@ class Notebook( Persistent ): self.update_revision() def __set_read_write( self, read_write ): + # The read_write member isn't actually saved to the database, so setting it doesn't need to + # call update_revision(). self.__read_write = read_write + def __set_deleted( self, deleted ): + self.__deleted = deleted + self.update_revision() + 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 ) + deleted = property( lambda self: self.__deleted, __set_deleted ) diff --git a/model/User.py b/model/User.py index 8e39acb..33289d9 100644 --- a/model/User.py +++ b/model/User.py @@ -129,19 +129,24 @@ class User( Persistent ): def sql_load_by_email_address( email_address ): return "select * from luminotes_user_current where email_address = %s;" % quote( email_address ) - def sql_load_notebooks( self, parents_only = False ): + def sql_load_notebooks( self, parents_only = False, deleted = False ): """ Return a SQL string to load a list of the notebooks to which this user has access. """ if parents_only: - parents_only_clause = " and trash_id is not null"; + parents_only_clause = " and trash_id is not null" else: parents_only_clause = "" + if deleted: + deleted_clause = " and deleted = 't'" + else: + deleted_clause = " and deleted = 'f'" + return \ "select notebook_current.*, user_notebook.read_write from user_notebook, notebook_current " + \ - "where user_id = %s%s and user_notebook.notebook_id = notebook_current.id order by revision;" % \ - ( quote( self.object_id ), parents_only_clause ) + "where user_id = %s%s%s and user_notebook.notebook_id = notebook_current.id order by revision;" % \ + ( quote( self.object_id ), parents_only_clause, deleted_clause ) def sql_save_notebook( self, notebook_id, read_write = True ): """ diff --git a/model/delta/1.0.1.sql b/model/delta/1.0.1.sql new file mode 100644 index 0000000..4bae3d7 --- /dev/null +++ b/model/delta/1.0.1.sql @@ -0,0 +1,3 @@ +alter table notebook add column deleted boolean default 'f'; +drop view notebook_current; +create view notebook_current as select notebook.id, notebook.revision, notebook.name, notebook.trash_id, notebook.deleted from notebook where (notebook.revision in (select max(sub_notebook.revision) as max from notebook sub_notebook where (sub_notebook.id = notebook.id))); diff --git a/model/schema.sql b/model/schema.sql index 60906ba..9f6654c 100644 --- a/model/schema.sql +++ b/model/schema.sql @@ -96,7 +96,8 @@ CREATE TABLE notebook ( id text NOT NULL, revision timestamp with time zone NOT NULL, name text, - trash_id text + trash_id text, + deleted boolean DEFAULT false ); @@ -107,7 +108,7 @@ ALTER TABLE public.notebook OWNER TO luminotes; -- CREATE VIEW notebook_current AS - SELECT notebook.id, notebook.revision, notebook.name, notebook.trash_id FROM notebook WHERE (notebook.revision IN (SELECT max(sub_notebook.revision) AS max FROM notebook sub_notebook WHERE (sub_notebook.id = notebook.id))); + SELECT notebook.id, notebook.revision, notebook.name, notebook.trash_id, notebook.deleted FROM notebook WHERE (notebook.revision IN (SELECT max(sub_notebook.revision) AS max FROM notebook sub_notebook WHERE (sub_notebook.id = notebook.id))); ALTER TABLE public.notebook_current OWNER TO luminotes; diff --git a/model/test/Test_notebook.py b/model/test/Test_notebook.py index 19cc38e..8331c99 100644 --- a/model/test/Test_notebook.py +++ b/model/test/Test_notebook.py @@ -12,8 +12,8 @@ class Test_notebook( object ): self.trash_name = u"trash" self.delta = timedelta( seconds = 1 ) - self.trash = Notebook.create( self.trash_id, self.trash_name, read_write = False ) - self.notebook = Notebook.create( self.object_id, self.name, trash_id = self.trash.object_id ) + self.trash = Notebook.create( self.trash_id, self.trash_name, read_write = False, deleted = False ) + self.notebook = Notebook.create( self.object_id, self.name, trash_id = self.trash.object_id, deleted = False ) self.note = Note.create( "19", u"