From: Marcus Wanner Date: Thu, 10 Nov 2011 08:00:11 +0000 (-0500) Subject: Adding 'move' button to web UI, closes #1579 X-Git-Url: https://git.rkrishnan.org/specifications/%5B/%5D%20/flags/quickstart.html?a=commitdiff_plain;h=b29d0920d380c06692c8939d848b09c86023990d;p=tahoe-lafs%2Ftahoe-lafs.git Adding 'move' button to web UI, closes #1579 This adds "move file" capability to the web UI's directory display. The support and test framework is heavily based on the similar "rename file" feature. Unit tests and documentation are included. Multiple in-progress versions of this patch may be found in ticket 1579. This version includes arbitrary URI target support and is compatible with the change from tahoe_css to tahoe.css. --- diff --git a/docs/frontends/webapi.rst b/docs/frontends/webapi.rst index f4e7063b..5f1060b1 100644 --- a/docs/frontends/webapi.rst +++ b/docs/frontends/webapi.rst @@ -29,8 +29,9 @@ The Tahoe REST-ful Web API 6. `Attaching An Existing File Or Directory (by URI)`_ 7. `Unlinking A Child`_ 8. `Renaming A Child`_ - 9. `Other Utilities`_ - 10. `Debugging and Testing Features`_ + 9. `Moving A Child`_ + 10. `Other Utilities`_ + 11. `Debugging and Testing Features`_ 7. `Other Useful Pages`_ 8. `Static Files in /public_html`_ @@ -1277,6 +1278,21 @@ Renaming A Child This operation will replace any existing child of the new name, making it behave like the UNIX "``mv -f``" command. +Moving A Child +---------------- + +``POST /uri/$DIRCAP/[SUBDIRS../]?t=rename&from_name=OLD&to_dir=TARGET[&to_name=NEW]`` + + This instructs the node to move a child of the given directory to a + different directory, both of which must be mutable. The child can also be + renamed in the process. The to_dir parameter can be either the name of a + subdirectory of the dircap from which the child is being moved (multiple + levels of descent are supported) or the writecap of an unrelated directory. + + This operation will replace any existing child of the new name, making it + behave like the UNIX "``mv -f``" command. The original child is not + unlinked until it is linked into the target directory. + Other Utilities --------------- @@ -1298,6 +1314,8 @@ Other Utilities functionality described above, with the provided $CHILDNAME present in the 'from_name' field of that form. I.e. this presents a form offering to rename $CHILDNAME, requesting the new name, and submitting POST rename. + This same URL format can also be used with "move-form" with the expected + results. ``GET /uri/$DIRCAP/[SUBDIRS../]CHILDNAME?t=uri`` diff --git a/src/allmydata/test/test_web.py b/src/allmydata/test/test_web.py index 4cebb432..a03d56c0 100644 --- a/src/allmydata/test/test_web.py +++ b/src/allmydata/test/test_web.py @@ -245,6 +245,7 @@ class WebMixin(object): self._sub_uri = sub_uri foo.set_uri(u"sub", sub_uri, sub_uri) sub = self.s.create_node_from_uri(sub_uri) + self._sub_node = sub _ign, n, blocking_uri = self.makefile(1) foo.set_uri(u"blockingfile", blocking_uri, blocking_uri) @@ -254,7 +255,7 @@ class WebMixin(object): # still think of it as an umlaut foo.set_uri(unicode_filename, self._bar_txt_uri, self._bar_txt_uri) - _ign, n, baz_file = self.makefile(2) + self.SUBBAZ_CONTENTS, n, baz_file = self.makefile(2) self._baz_file_uri = baz_file sub.set_uri(u"baz.txt", baz_file, baz_file) @@ -309,6 +310,9 @@ class WebMixin(object): def failUnlessIsBazDotTxt(self, res): self.failUnlessReallyEqual(res, self.BAZ_CONTENTS, res) + def failUnlessIsSubBazDotTxt(self, res): + self.failUnlessReallyEqual(res, self.SUBBAZ_CONTENTS, res) + def failUnlessIsBarJSON(self, res): data = simplejson.loads(res) self.failUnless(isinstance(data, list)) @@ -1258,7 +1262,7 @@ class Web(WebMixin, WebErrorMixin, testutil.StallMixin, testutil.ReallyEqualMixi r'\s+%d' % len(self.BAR_CONTENTS), ]) self.failUnless(re.search(get_bar, res), res) - for label in ['unlink', 'rename']: + for label in ['unlink', 'rename', 'move']: for line in res.split("\n"): # find the line that contains the relevant button for bar.txt if ("form action" in line and @@ -3242,6 +3246,151 @@ class Web(WebMixin, WebErrorMixin, testutil.StallMixin, testutil.ReallyEqualMixi d.addCallback(self.failUnlessIsFooJSON) return d + def test_POST_move_file(self): + """""" + d = self.POST(self.public_url + "/foo", t="move", + from_name="bar.txt", to_dir="sub") + d.addCallback(lambda res: + self.failIfNodeHasChild(self._foo_node, u"bar.txt")) + d.addCallback(lambda res: + self.failUnlessNodeHasChild(self._sub_node, u"bar.txt")) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/bar.txt")) + d.addCallback(self.failUnlessIsBarDotTxt) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/bar.txt?t=json")) + d.addCallback(self.failUnlessIsBarJSON) + return d + + def test_POST_move_file_new_name(self): + d = self.POST(self.public_url + "/foo", t="move", + from_name="bar.txt", to_name="wibble.txt", to_dir="sub") + d.addCallback(lambda res: + self.failIfNodeHasChild(self._foo_node, u"bar.txt")) + d.addCallback(lambda res: + self.failIfNodeHasChild(self._sub_node, u"bar.txt")) + d.addCallback(lambda res: + self.failUnlessNodeHasChild(self._sub_node, u"wibble.txt")) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/wibble.txt")) + d.addCallback(self.failUnlessIsBarDotTxt) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/wibble.txt?t=json")) + d.addCallback(self.failUnlessIsBarJSON) + return d + + def test_POST_move_file_replace(self): + d = self.POST(self.public_url + "/foo", t="move", + from_name="bar.txt", to_name="baz.txt", to_dir="sub") + d.addCallback(lambda res: + self.failIfNodeHasChild(self._foo_node, u"bar.txt")) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/baz.txt")) + d.addCallback(self.failUnlessIsBarDotTxt) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/baz.txt?t=json")) + d.addCallback(self.failUnlessIsBarJSON) + return d + + def test_POST_move_file_no_replace(self): + d = self.POST(self.public_url + "/foo", t="move", replace="false", + from_name="bar.txt", to_name="baz.txt", to_dir="sub") + d.addBoth(self.shouldFail, error.Error, + "POST_move_file_no_replace", + "409 Conflict", + "There was already a child by that name, and you asked me " + "to not replace it") + d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt")) + d.addCallback(self.failUnlessIsBarDotTxt) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt?t=json")) + d.addCallback(self.failUnlessIsBarJSON) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/baz.txt")) + d.addCallback(self.failUnlessIsSubBazDotTxt) + return d + + def test_POST_move_file_slash_fail(self): + d = self.POST(self.public_url + "/foo", t="move", + from_name="bar.txt", to_name="slash/fail.txt", to_dir="sub") + d.addBoth(self.shouldFail, error.Error, + "test_POST_rename_file_slash_fail", + "400 Bad Request", + "to_name= may not contain a slash", + ) + d.addCallback(lambda res: + self.failUnlessNodeHasChild(self._foo_node, u"bar.txt")) + d.addCallback(lambda res: + self.failIfNodeHasChild(self._sub_node, u"slash/fail.txt")) + return d + + def test_POST_move_file_no_target(self): + d = self.POST(self.public_url + "/foo", t="move", + from_name="bar.txt", to_name="baz.txt") + d.addBoth(self.shouldFail, error.Error, + "POST_move_file_no_target", + "400 Bad Request", + "move requires from_name and to_dir") + d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt")) + d.addCallback(self.failUnlessIsBarDotTxt) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt?t=json")) + d.addCallback(self.failUnlessIsBarJSON) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/baz.txt")) + d.addCallback(self.failUnlessIsBazDotTxt) + return d + + def test_POST_move_file_multi_level(self): + d = self.POST(self.public_url + "/foo/sub/level2?t=mkdir", "") + d.addCallback(lambda res: self.POST(self.public_url + "/foo", t="move", + from_name="bar.txt", to_dir="sub/level2")) + d.addCallback(lambda res: self.failIfNodeHasChild(self._foo_node, u"bar.txt")) + d.addCallback(lambda res: self.failIfNodeHasChild(self._sub_node, u"bar.txt")) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/level2/bar.txt")) + d.addCallback(self.failUnlessIsBarDotTxt) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/level2/bar.txt?t=json")) + d.addCallback(self.failUnlessIsBarJSON) + return d + + def test_POST_move_file_to_uri(self): + d = self.POST(self.public_url + "/foo", t="move", + from_name="bar.txt", to_dir=self._sub_uri) + d.addCallback(lambda res: + self.failIfNodeHasChild(self._foo_node, u"bar.txt")) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/bar.txt")) + d.addCallback(self.failUnlessIsBarDotTxt) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/bar.txt?t=json")) + d.addCallback(self.failUnlessIsBarJSON) + return d + + def test_POST_move_file_to_nonexist_dir(self): + d = self.POST(self.public_url + "/foo", t="move", + from_name="bar.txt", to_dir="notchucktesta") + d.addBoth(self.shouldFail, error.Error, + "POST_move_file_to_nonexist_dir", + "404 Not Found", + "No such child: notchucktesta") + return d + + def test_POST_move_file_into_file(self): + d = self.POST(self.public_url + "/foo", t="move", + from_name="bar.txt", to_dir="baz.txt") + d.addBoth(self.shouldFail, error.Error, + "POST_move_file_into_file", + "410 Gone", + "to_dir is not a usable directory") + d.addCallback(lambda res: self.GET(self.public_url + "/foo/baz.txt")) + d.addCallback(self.failUnlessIsBazDotTxt) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt")) + d.addCallback(self.failUnlessIsBarDotTxt) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt?t=json")) + d.addCallback(self.failUnlessIsBarJSON) + return d + + def test_POST_move_file_to_bad_uri(self): + d = self.POST(self.public_url + "/foo", t="move", from_name="bar.txt", + to_dir="URI:DIR2:mn5jlyjnrjeuydyswlzyui72i:rmneifcj6k6sycjljjhj3f6majsq2zqffydnnul5hfa4j577arma") + d.addBoth(self.shouldFail, error.Error, + "POST_move_file_to_bad_uri", + "410 Gone", + "to_dir is not a usable directory") + d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt")) + d.addCallback(self.failUnlessIsBarDotTxt) + d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt?t=json")) + d.addCallback(self.failUnlessIsBarJSON) + return d + def shouldRedirect(self, res, target=None, statuscode=None, which=""): """ If target is not None then the redirection has to go to target. If statuscode is not None then the redirection has to be accomplished with @@ -3299,6 +3448,15 @@ class Web(WebMixin, WebErrorMixin, testutil.StallMixin, testutil.ReallyEqualMixi d.addCallback(_check) return d + def test_GET_move_form(self): + d = self.GET(self.public_url + "/foo?t=move-form&name=bar.txt", + followRedirect=True) + def _check(res): + self.failUnless('name="when_done" value="."' in res, res) + self.failUnless(re.search(r'name="from_name" value="bar\.txt"', res)) + d.addCallback(_check) + return d + def log(self, res, msg): #print "MSG: %s RES: %s" % (msg, res) log.msg(msg) diff --git a/src/allmydata/uri.py b/src/allmydata/uri.py index ee9c86aa..dfa6cfa9 100644 --- a/src/allmydata/uri.py +++ b/src/allmydata/uri.py @@ -934,6 +934,13 @@ def is_literal_file_uri(s): s.startswith(ALLEGED_READONLY_PREFIX + 'URI:LIT:') or s.startswith(ALLEGED_IMMUTABLE_PREFIX + 'URI:LIT:')) +def is_writeable_directory_uri(s): + if not isinstance(s, str): + return False + return (s.startswith('URI:DIR2:') or + s.startswith(ALLEGED_READONLY_PREFIX + 'URI:DIR2:') or + s.startswith(ALLEGED_IMMUTABLE_PREFIX + 'URI:DIR2:')) + def has_uri_prefix(s): if not isinstance(s, str): return False diff --git a/src/allmydata/web/directory.py b/src/allmydata/web/directory.py index 788f3399..d4f88de2 100644 --- a/src/allmydata/web/directory.py +++ b/src/allmydata/web/directory.py @@ -13,7 +13,7 @@ from nevow.inevow import IRequest from foolscap.api import fireEventually from allmydata.util import base32, time_format -from allmydata.uri import from_string_dirnode +from allmydata.uri import from_string_dirnode, is_writeable_directory_uri from allmydata.interfaces import IDirectoryNode, IFileNode, IFilesystemNode, \ IImmutableFileNode, IMutableFileNode, ExistingChildError, \ NoSuchChildError, EmptyPathnameComponentError, SDMF_VERSION, MDMF_VERSION @@ -169,6 +169,8 @@ class DirectoryNodeHandler(RenderMixin, rend.Page, ReplaceMeMixin): return DirectoryReadonlyURI(ctx, self.node) if t == 'rename-form': return RenameForm(self.node) + if t == 'move-form': + return MoveForm(self.node) raise WebError("GET directory: bad t=%s" % t) @@ -213,6 +215,8 @@ class DirectoryNodeHandler(RenderMixin, rend.Page, ReplaceMeMixin): d = self._POST_unlink(req) elif t == "rename": d = self._POST_rename(req) + elif t == "move": + d = self._POST_move(req) elif t == "check": d = self._POST_check(req) elif t == "start-deep-check": @@ -418,6 +422,52 @@ class DirectoryNodeHandler(RenderMixin, rend.Page, ReplaceMeMixin): d.addCallback(lambda res: "thing renamed") return d + def _POST_move(self, req): + charset = get_arg(req, "_charset", "utf-8") + from_name = get_arg(req, "from_name") + if from_name is not None: + from_name = from_name.strip() + from_name = from_name.decode(charset) + assert isinstance(from_name, unicode) + to_name = get_arg(req, "to_name") + if to_name is not None: + to_name = to_name.strip() + to_name = to_name.decode(charset) + assert isinstance(to_name, unicode) + if not to_name: + to_name = from_name + to_dir = get_arg(req, "to_dir") + if to_dir is not None: + to_dir = to_dir.strip() + to_dir = to_dir.decode(charset) + assert isinstance(to_dir, unicode) + if not from_name or not to_dir: + raise WebError("move requires from_name and to_dir") + replace = boolean_of_arg(get_arg(req, "replace", "true")) + + # allow from_name to contain slashes, so they can fix names that + # were accidentally created with them. But disallow them in to_name + # (if it's specified), to discourage the practice. + if to_name and "/" in to_name: + raise WebError("to_name= may not contain a slash", http.BAD_REQUEST) + + d = self.node.has_child(to_dir.split('/')[0]) + def get_target_node(isname): + if isname or not is_writeable_directory_uri(str(to_dir)): + return self.node.get_child_at_path(to_dir) + else: + return self.client.create_node_from_uri(str(to_dir)) + d.addCallback(get_target_node) + def is_target_node_usable(target_node): + if not IDirectoryNode.providedBy(target_node): + raise WebError("to_dir is not a usable directory", http.GONE) + return target_node + d.addCallback(is_target_node_usable) + d.addCallback(lambda new_parent: self.node.move_child_to( + from_name, new_parent, to_name, replace)) + d.addCallback(lambda res: "thing moved") + return d + def _maybe_literal(self, res, Results_Class): if res: return Results_Class(self.client, res) @@ -662,6 +712,7 @@ class DirectoryAsHTML(rend.Page): if self.node.is_unknown() or self.node.is_readonly(): unlink = "-" rename = "-" + move = "-" else: # this creates a button which will cause our _POST_unlink method # to be invoked, which unlinks the file and then redirects the @@ -680,8 +731,16 @@ class DirectoryAsHTML(rend.Page): T.input(type='submit', value='rename', name="rename"), ] + move = T.form(action=here, method="get")[ + T.input(type='hidden', name='t', value='move-form'), + T.input(type='hidden', name='name', value=name), + T.input(type='hidden', name='when_done', value="."), + T.input(type='submit', value='move', name="move"), + ] + ctx.fillSlots("unlink", unlink) ctx.fillSlots("rename", rename) + ctx.fillSlots("move", move) times = [] linkcrtime = metadata.get('tahoe', {}).get("linkcrtime") @@ -943,6 +1002,32 @@ class RenameForm(rend.Page): ctx.tag.attributes['value'] = name return ctx.tag +class MoveForm(rend.Page): + addSlash = True + docFactory = getxmlfile("move-form.xhtml") + + def render_title(self, ctx, data): + return ctx.tag["Directory SI=%s" % abbreviated_dirnode(self.original)] + + def render_header(self, ctx, data): + header = ["Move " + "from directory SI=%s" % abbreviated_dirnode(self.original), + ] + + if self.original.is_readonly(): + header.append(" (readonly!)") + header.append(":") + return ctx.tag[header] + + def render_when_done(self, ctx, data): + return T.input(type="hidden", name="when_done", value=".") + + def render_get_name(self, ctx, data): + req = IRequest(ctx) + name = get_arg(req, "name", "") + ctx.tag.attributes['value'] = name + return ctx.tag + class ManifestResults(rend.Page, ReloadMixin): docFactory = getxmlfile("manifest.xhtml") diff --git a/src/allmydata/web/directory.xhtml b/src/allmydata/web/directory.xhtml index 540da00d..6c2b5800 100644 --- a/src/allmydata/web/directory.xhtml +++ b/src/allmydata/web/directory.xhtml @@ -33,6 +33,7 @@ + diff --git a/src/allmydata/web/move-form.xhtml b/src/allmydata/web/move-form.xhtml new file mode 100644 index 00000000..51baae7d --- /dev/null +++ b/src/allmydata/web/move-form.xhtml @@ -0,0 +1,30 @@ + + + + + + + + + +

+ +
+
+
+ Rename child + + + + Move child: + + to +
+ New name? + + +
+
+
+ +