witten
/
luminotes
Archived
1
0
Fork 0

Began work on notebook deletion and subsequent undo/undeletion.

Changed schema slightly to support this.
Added a schema delta file and wrote an UPGRADE doc with info on how to upgrade schemas.
This commit is contained in:
Dan Helfman 2007-11-17 04:21:48 +00:00
parent c67aba8fbc
commit cdd971780e
13 changed files with 376 additions and 23 deletions

12
UPGRADE Normal file
View File

@ -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.

View File

@ -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

View File

@ -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 = []

View File

@ -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,

View File

@ -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 )

View File

@ -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 ):
"""

3
model/delta/1.0.1.sql Normal file
View File

@ -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)));

View File

@ -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;

View File

@ -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"<h3>title</h3>blah" )
def test_create( self ):
@ -22,12 +22,14 @@ class Test_notebook( object ):
assert self.notebook.name == self.name
assert self.notebook.read_write == True
assert self.notebook.trash_id == self.trash_id
assert self.notebook.deleted == False
assert self.trash.object_id == self.trash_id
assert datetime.now( tz = utc ) - self.trash.revision < self.delta
assert self.trash.name == self.trash_name
assert self.trash.read_write == False
assert self.trash.trash_id == None
assert self.trash.deleted == False
def test_set_name( self ):
new_name = u"my new notebook"
@ -44,11 +46,19 @@ class Test_notebook( object ):
assert self.notebook.read_write == True
assert self.notebook.revision == original_revision
def test_set_deleted( self ):
previous_revision = self.notebook.revision
self.notebook.deleted = True
assert self.notebook.deleted == True
assert self.notebook.revision > previous_revision
def test_to_dict( self ):
d = self.notebook.to_dict()
assert d.get( "name" ) == self.name
assert d.get( "trash_id" ) == self.trash.object_id
assert d.get( "read_write" ) == True
assert d.get( "deleted" ) == self.notebook.deleted
assert d.get( "object_id" ) == self.notebook.object_id
assert datetime.now( tz = utc ) - d.get( "revision" ) < self.delta

View File

@ -39,6 +39,23 @@ function Wiki( invoker ) {
alert( "Luminotes does not currently support the " + unsupported_agent + " web browser for editing. If possible, please use Firefox or Internet Explorer instead. " + unsupported_agent + " support will be added in a future release. Sorry for the inconvenience." );
}
// if a notebook was just deleted, show a message with an undo button
var deleted_id = getElement( "deleted_id" ).value;
if ( deleted_id && this.notebook.read_write ) {
var undo_button = createDOM( "input", {
"type": "button",
"class": "message_button",
"value": "undo",
"title": "undo deletion"
} );
var trash_link = createDOM( "a", {
"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 ] );
var self = this;
connect( undo_button, "onclick", function ( event ) { self.undelete_notebook_via_undo( event, deleted_id, message_div ); } );
}
// populate the wiki with startup notes
this.populate(
evalJSON( getElement( "startup_notes" ).value || "null" ),
@ -78,7 +95,7 @@ function Wiki( invoker ) {
}
var rename = evalJSON( getElement( "rename" ).value );
if ( rename )
if ( rename && this.notebook.read_write )
this.start_notebook_rename();
}
@ -235,6 +252,14 @@ Wiki.prototype.populate = function ( startup_notes, current_notes, note_read_wri
event.stop();
} );
}
var rename_notebook_link = getElement( "delete_notebook_link" );
if ( rename_notebook_link ) {
connect( rename_notebook_link, "onclick", function ( event ) {
self.delete_notebook();
event.stop();
} );
}
}
Wiki.prototype.background_clicked = function ( event ) {
@ -945,6 +970,13 @@ Wiki.prototype.undelete_editor_via_undelete = function( event, note_id, position
event.stop();
}
Wiki.prototype.undelete_notebook_via_undo = function( event, notebook_id, position_after ) {
this.invoker.invoke( "/notebooks/undelete", "POST", {
"notebook_id": notebook_id,
} );
event.stop();
}
Wiki.prototype.compare_versions = function( event, editor, previous_revision ) {
this.clear_pulldowns();
@ -1436,6 +1468,12 @@ Wiki.prototype.end_notebook_rename = function () {
} );
}
Wiki.prototype.delete_notebook = function () {
this.invoker.invoke( "/notebooks/delete", "POST", {
"notebook_id": this.notebook_id,
} );
}
Wiki.prototype.toggle_editor_changes = function ( event, editor ) {
// if the pulldown is already open, then just close it
var pulldown_id = "changes_" + editor.id;

View File

@ -84,6 +84,7 @@ function test_Wiki() {
<input type="hidden" name="note" id="note" value="" />
<input type="hidden" name="note_read_write" id="note_read_write" value="" />
<input type="hidden" name="rename" id="rename" value="false" />
<input type="hidden" name="deleted_id" id="deleted_id" value="" />
<div id="static_notes">
</div>

View File

@ -55,6 +55,16 @@ class Link_area( Div ):
class_ = u"link_area_item",
) or None,
( notebook.name != u"trash" ) and Div(
A(
u"delete notebook",
href = u"#",
id = u"delete_notebook_link",
title = u"Move this notebook to the trash.",
),
class_ = u"link_area_item",
) or None,
notebook.trash_id and Div(
A(
u"trash",

View File

@ -28,6 +28,7 @@ class Main_page( Page ):
http_url = None,
conversion = None,
rename = False,
deleted_id = None,
):
startup_note_ids = [ startup_note.object_id for startup_note in startup_notes ]
@ -96,6 +97,7 @@ class Main_page( Page ):
Input( type = u"hidden", name = u"current_notes", id = u"current_notes", value = json( note_dicts ) ),
Input( type = u"hidden", name = u"note_read_write", id = u"note_read_write", value = json( note_read_write ) ),
Input( type = u"hidden", name = u"rename", id = u"rename", value = json( rename ) ),
Input( type = u"hidden", name = u"deleted_id", id = u"deleted_id", value = deleted_id ),
Div(
id = u"status_area",
),