]> git.rkrishnan.org Git - tahoe-lafs/tahoe-lafs.git/commitdiff
Adding 'move' button to web UI, closes #1579
authorMarcus Wanner <marcus@wanners.net>
Thu, 10 Nov 2011 08:00:11 +0000 (03:00 -0500)
committerBrian Warner <warner@lothar.com>
Wed, 9 May 2012 20:07:13 +0000 (13:07 -0700)
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.

docs/frontends/webapi.rst
src/allmydata/test/test_web.py
src/allmydata/uri.py
src/allmydata/web/directory.py
src/allmydata/web/directory.xhtml
src/allmydata/web/move-form.xhtml [new file with mode: 0644]

index f4e7063be6095c1a0f1e12b90a347066e3033697..5f1060b1a9485d4433e589a25db0723a2ebd4ef7 100644 (file)
@@ -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``
 
index 4cebb432491d9d278c2b23e4dfc6afbb8f598e96..a03d56c0b66ff71fb77f3943b7f0bda64c3949b0 100644 (file)
@@ -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+<td align="right">%d</td>' % 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)
index ee9c86aaa52ff0f4b2d636fbb252d7e7600f6d66..dfa6cfa9425fab7ba3f69ef9b14a046810f10dd3 100644 (file)
@@ -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
index 788f3399d3b36fd0a6c7972c525df8e8ff997cd1..d4f88de29125d403ae536a2c56911b5892ae3841 100644 (file)
@@ -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")
index 540da00d9f801e3bd52509e9654871bb3770b2c6..6c2b58002d03f26b2e5b9e54191c00c96a4e3f9d 100644 (file)
@@ -33,6 +33,7 @@
       <td><n:slot name="times"/></td>
       <td><n:slot name="unlink"/></td>
       <td><n:slot name="rename"/></td>
+      <td><n:slot name="move"/></td>
       <td><n:slot name="info"/></td>
     </tr>
 
diff --git a/src/allmydata/web/move-form.xhtml b/src/allmydata/web/move-form.xhtml
new file mode 100644 (file)
index 0000000..51baae7
--- /dev/null
@@ -0,0 +1,30 @@
+<html xmlns:n="http://nevow.com/ns/nevow/0.1">
+  <head>
+    <title n:render="title"></title>
+    <link href="/tahoe.css" rel="stylesheet" type="text/css"/>
+    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+  </head>
+
+<body>
+
+<h2 n:render="header" />
+
+<div class="freeform-form">
+    <form action="." method="post" enctype="multipart/form-data">
+        <fieldset>
+            <legend class="freeform-form-label">Rename child</legend>
+            <input type="hidden" name="t" value="move" />
+            <input n:render="when_done" />
+
+            Move child:
+            <input type="text" name="from_name" readonly="true" n:render="get_name" />
+            to
+            <input type="text" name="to_dir" /><br />
+            New name?
+            <input type="text" name="to_name" />
+            <input type="submit" value="move" />
+        </fieldset>
+    </form>
+</div>
+
+</body></html>