Ticket #1579: movebutton.diff

File movebutton.diff, 20.1 KB (added by marcusw, at 2011-11-17T02:06:55Z)

"Final" complete patch including tests, docs, and the actual code.

  • docs/frontends/webapi.rst

    diff --git a/docs/frontends/webapi.rst b/docs/frontends/webapi.rst
    index 47ab754..a715a50 100644
    a b The Tahoe REST-ful Web API 
    2929    6.  `Attaching An Existing File Or Directory (by URI)`_
    3030    7.  `Unlinking A Child`_
    3131    8.  `Renaming A Child`_
    32     9.  `Other Utilities`_
    33     10. `Debugging and Testing Features`_
     32    9.  `Moving A Child`_
     33    10. `Other Utilities`_
     34    11. `Debugging and Testing Features`_
    3435
    35367.  `Other Useful Pages`_
    36378.  `Static Files in /public_html`_
    Renaming A Child 
    12731274 This operation will replace any existing child of the new name, making it
    12741275 behave like the UNIX "``mv -f``" command.
    12751276
     1277Moving A Child
     1278----------------
     1279
     1280``POST /uri/$DIRCAP/[SUBDIRS../]?t=rename&from_name=OLD&to_dir=TARGET[&to_name=NEW]``
     1281
     1282 This instructs the node to move a child of the given directory to a
     1283 different directory, both of which must be mutable. The child can also be
     1284 renamed in the process. The to_dir parameter can be either the name of a
     1285 subdirectory of the dircap from which the child is being moved (multiple
     1286 levels of descent are supported) or the writecap of an unrelated directory.
     1287
     1288 This operation will replace any existing child of the new name, making it
     1289 behave like the UNIX "``mv -f``" command. The original child is not
     1290 unlinked until it is linked into the target directory.
     1291
    12761292Other Utilities
    12771293---------------
    12781294
    Other Utilities 
    12941310  functionality described above, with the provided $CHILDNAME present in the
    12951311  'from_name' field of that form. I.e. this presents a form offering to
    12961312  rename $CHILDNAME, requesting the new name, and submitting POST rename.
     1313  This same URL format can also be used with "move-form" with the expected
     1314  results.
    12971315
    12981316``GET /uri/$DIRCAP/[SUBDIRS../]CHILDNAME?t=uri``
    12991317
  • src/allmydata/test/test_web.py

    diff --git a/src/allmydata/test/test_web.py b/src/allmydata/test/test_web.py
    index dc7ab9e..0913f0d 100644
    a b class WebMixin(object): 
    236236            self._sub_uri = sub_uri
    237237            foo.set_uri(u"sub", sub_uri, sub_uri)
    238238            sub = self.s.create_node_from_uri(sub_uri)
     239            self._sub_node = sub
    239240
    240241            _ign, n, blocking_uri = self.makefile(1)
    241242            foo.set_uri(u"blockingfile", blocking_uri, blocking_uri)
    class WebMixin(object): 
    245246            # still think of it as an umlaut
    246247            foo.set_uri(unicode_filename, self._bar_txt_uri, self._bar_txt_uri)
    247248
    248             _ign, n, baz_file = self.makefile(2)
     249            self.SUBBAZ_CONTENTS, n, baz_file = self.makefile(2)
    249250            self._baz_file_uri = baz_file
    250251            sub.set_uri(u"baz.txt", baz_file, baz_file)
    251252
    class WebMixin(object): 
    300301    def failUnlessIsBazDotTxt(self, res):
    301302        self.failUnlessReallyEqual(res, self.BAZ_CONTENTS, res)
    302303
     304    def failUnlessIsSubBazDotTxt(self, res):
     305        self.failUnlessReallyEqual(res, self.SUBBAZ_CONTENTS, res)
     306
    303307    def failUnlessIsBarJSON(self, res):
    304308        data = simplejson.loads(res)
    305309        self.failUnless(isinstance(data, list))
    class Web(WebMixin, WebErrorMixin, testutil.StallMixin, testutil.ReallyEqualMixi 
    13351339                               r'\s+<td align="right">%d</td>' % len(self.BAR_CONTENTS),
    13361340                               ])
    13371341            self.failUnless(re.search(get_bar, res), res)
    1338             for label in ['unlink', 'rename']:
     1342            for label in ['unlink', 'rename', 'move']:
    13391343                for line in res.split("\n"):
    13401344                    # find the line that contains the relevant button for bar.txt
    13411345                    if ("form action" in line and
    class Web(WebMixin, WebErrorMixin, testutil.StallMixin, testutil.ReallyEqualMixi 
    33163320        d.addCallback(self.failUnlessIsFooJSON)
    33173321        return d
    33183322
     3323    def test_POST_move_file(self):
     3324        """"""
     3325        d = self.POST(self.public_url + "/foo", t="move",
     3326                      from_name="bar.txt", to_dir="sub")
     3327        d.addCallback(lambda res:
     3328                      self.failIfNodeHasChild(self._foo_node, u"bar.txt"))
     3329        d.addCallback(lambda res:
     3330                      self.failUnlessNodeHasChild(self._sub_node, u"bar.txt"))
     3331        d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/bar.txt"))
     3332        d.addCallback(self.failUnlessIsBarDotTxt)
     3333        d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/bar.txt?t=json"))
     3334        d.addCallback(self.failUnlessIsBarJSON)
     3335        return d
     3336
     3337    def test_POST_move_file_new_name(self):
     3338        d = self.POST(self.public_url + "/foo", t="move",
     3339                      from_name="bar.txt", to_name="wibble.txt", to_dir="sub")
     3340        d.addCallback(lambda res:
     3341                      self.failIfNodeHasChild(self._foo_node, u"bar.txt"))
     3342        d.addCallback(lambda res:
     3343                      self.failIfNodeHasChild(self._sub_node, u"bar.txt"))
     3344        d.addCallback(lambda res:
     3345                      self.failUnlessNodeHasChild(self._sub_node, u"wibble.txt"))
     3346        d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/wibble.txt"))
     3347        d.addCallback(self.failUnlessIsBarDotTxt)
     3348        d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/wibble.txt?t=json"))
     3349        d.addCallback(self.failUnlessIsBarJSON)
     3350        return d
     3351
     3352    def test_POST_move_file_replace(self):
     3353        d = self.POST(self.public_url + "/foo", t="move",
     3354                      from_name="bar.txt", to_name="baz.txt", to_dir="sub")
     3355        d.addCallback(lambda res:
     3356                      self.failIfNodeHasChild(self._foo_node, u"bar.txt"))
     3357        d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/baz.txt"))
     3358        d.addCallback(self.failUnlessIsBarDotTxt)
     3359        d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/baz.txt?t=json"))
     3360        d.addCallback(self.failUnlessIsBarJSON)
     3361        return d
     3362
     3363    def test_POST_move_file_no_replace(self):
     3364        d = self.POST(self.public_url + "/foo", t="move", replace="false",
     3365                      from_name="bar.txt", to_name="baz.txt", to_dir="sub")
     3366        d.addBoth(self.shouldFail, error.Error,
     3367                  "POST_move_file_no_replace",
     3368                  "409 Conflict",
     3369                  "There was already a child by that name, and you asked me "
     3370                  "to not replace it")
     3371        d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt"))
     3372        d.addCallback(self.failUnlessIsBarDotTxt)
     3373        d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt?t=json"))
     3374        d.addCallback(self.failUnlessIsBarJSON)
     3375        d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/baz.txt"))
     3376        d.addCallback(self.failUnlessIsSubBazDotTxt)
     3377        return d
     3378
     3379    def test_POST_move_file_slash_fail(self):
     3380        d = self.POST(self.public_url + "/foo", t="move",
     3381                      from_name="bar.txt", to_name="slash/fail.txt", to_dir="sub")
     3382        d.addBoth(self.shouldFail, error.Error,
     3383                  "test_POST_rename_file_slash_fail",
     3384                  "400 Bad Request",
     3385                  "to_name= may not contain a slash",
     3386                  )
     3387        d.addCallback(lambda res:
     3388                      self.failUnlessNodeHasChild(self._foo_node, u"bar.txt"))
     3389        d.addCallback(lambda res:
     3390                      self.failIfNodeHasChild(self._sub_node, u"slash/fail.txt"))
     3391        return d
     3392
     3393    def test_POST_move_file_no_target(self):
     3394        d = self.POST(self.public_url + "/foo", t="move",
     3395                      from_name="bar.txt", to_name="baz.txt")
     3396        d.addBoth(self.shouldFail, error.Error,
     3397                  "POST_move_file_no_target",
     3398                  "400 Bad Request",
     3399                  "move requires from_name and to_dir")
     3400        d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt"))
     3401        d.addCallback(self.failUnlessIsBarDotTxt)
     3402        d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt?t=json"))
     3403        d.addCallback(self.failUnlessIsBarJSON)
     3404        d.addCallback(lambda res: self.GET(self.public_url + "/foo/baz.txt"))
     3405        d.addCallback(self.failUnlessIsBazDotTxt)
     3406        return d
     3407
     3408    def test_POST_move_file_multi_level(self):
     3409        d = self.POST(self.public_url + "/foo/sub/level2?t=mkdir", "")
     3410        d.addCallback(lambda res: self.POST(self.public_url + "/foo", t="move",
     3411                      from_name="bar.txt", to_dir="sub/level2"))
     3412        d.addCallback(lambda res: self.failIfNodeHasChild(self._foo_node, u"bar.txt"))
     3413        d.addCallback(lambda res: self.failIfNodeHasChild(self._sub_node, u"bar.txt"))
     3414        d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/level2/bar.txt"))
     3415        d.addCallback(self.failUnlessIsBarDotTxt)
     3416        d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/level2/bar.txt?t=json"))
     3417        d.addCallback(self.failUnlessIsBarJSON)
     3418        return d
     3419
     3420    def test_POST_move_file_to_uri(self):
     3421        d = self.POST(self.public_url + "/foo", t="move",
     3422                      from_name="bar.txt", to_dir=self._sub_uri)
     3423        d.addCallback(lambda res:
     3424                      self.failIfNodeHasChild(self._foo_node, u"bar.txt"))
     3425        d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/bar.txt"))
     3426        d.addCallback(self.failUnlessIsBarDotTxt)
     3427        d.addCallback(lambda res: self.GET(self.public_url + "/foo/sub/bar.txt?t=json"))
     3428        d.addCallback(self.failUnlessIsBarJSON)
     3429        return d
     3430
     3431    def test_POST_move_file_to_nonexist_dir(self):
     3432        d = self.POST(self.public_url + "/foo", t="move",
     3433                      from_name="bar.txt", to_dir="notchucktesta")
     3434        d.addBoth(self.shouldFail, error.Error,
     3435                  "POST_move_file_to_nonexist_dir",
     3436                  "404 Not Found",
     3437                  "No such child: notchucktesta")
     3438        return d
     3439
     3440    def test_POST_move_file_into_file(self):
     3441        d = self.POST(self.public_url + "/foo", t="move",
     3442                      from_name="bar.txt", to_dir="baz.txt")
     3443        d.addBoth(self.shouldFail, error.Error,
     3444                  "POST_move_file_into_file",
     3445                  "410 Gone",
     3446                  "to_dir is not a usable directory")
     3447        d.addCallback(lambda res: self.GET(self.public_url + "/foo/baz.txt"))
     3448        d.addCallback(self.failUnlessIsBazDotTxt)
     3449        d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt"))
     3450        d.addCallback(self.failUnlessIsBarDotTxt)
     3451        d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt?t=json"))
     3452        d.addCallback(self.failUnlessIsBarJSON)
     3453        return d
     3454
     3455    def test_POST_move_file_to_bad_uri(self):
     3456        d = self.POST(self.public_url + "/foo", t="move", from_name="bar.txt",
     3457                      to_dir="URI:DIR2:mn5jlyjnrjeuydyswlzyui72i:rmneifcj6k6sycjljjhj3f6majsq2zqffydnnul5hfa4j577arma")
     3458        d.addBoth(self.shouldFail, error.Error,
     3459                  "POST_move_file_to_bad_uri",
     3460                  "410 Gone",
     3461                  "to_dir is not a usable directory")
     3462        d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt"))
     3463        d.addCallback(self.failUnlessIsBarDotTxt)
     3464        d.addCallback(lambda res: self.GET(self.public_url + "/foo/bar.txt?t=json"))
     3465        d.addCallback(self.failUnlessIsBarJSON)
     3466        return d
     3467
    33193468    def shouldRedirect(self, res, target=None, statuscode=None, which=""):
    33203469        """ If target is not None then the redirection has to go to target.  If
    33213470        statuscode is not None then the redirection has to be accomplished with
    class Web(WebMixin, WebErrorMixin, testutil.StallMixin, testutil.ReallyEqualMixi 
    33723521        d.addCallback(_check)
    33733522        return d
    33743523
     3524    def test_GET_move_form(self):
     3525        d = self.GET(self.public_url + "/foo?t=move-form&name=bar.txt",
     3526                     followRedirect=True)
     3527        def _check(res):
     3528            self.failUnless('name="when_done" value="."' in res, res)
     3529            self.failUnless(re.search(r'name="from_name" value="bar\.txt"', res))
     3530        d.addCallback(_check)
     3531        return d
     3532
    33753533    def log(self, res, msg):
    33763534        #print "MSG: %s  RES: %s" % (msg, res)
    33773535        log.msg(msg)
  • src/allmydata/uri.py

    diff --git a/src/allmydata/uri.py b/src/allmydata/uri.py
    index 8cb9d10..e2b78a2 100644
    a b def is_literal_file_uri(s): 
    934934            s.startswith(ALLEGED_READONLY_PREFIX + 'URI:LIT:') or
    935935            s.startswith(ALLEGED_IMMUTABLE_PREFIX + 'URI:LIT:'))
    936936
     937def is_writeable_directory_uri(s):
     938    if not isinstance(s, str):
     939        return False
     940    return (s.startswith('URI:DIR2:') or
     941            s.startswith(ALLEGED_READONLY_PREFIX + 'URI:DIR2:') or
     942            s.startswith(ALLEGED_IMMUTABLE_PREFIX + 'URI:DIR2:'))
     943
    937944def has_uri_prefix(s):
    938945    if not isinstance(s, str):
    939946        return False
  • src/allmydata/web/directory.py

    diff --git a/src/allmydata/web/directory.py b/src/allmydata/web/directory.py
    index 58fe781..90ef36a 100644
    a b from nevow.inevow import IRequest 
    1313from foolscap.api import fireEventually
    1414
    1515from allmydata.util import base32, time_format
    16 from allmydata.uri import from_string_dirnode
     16from allmydata.uri import from_string_dirnode, is_writeable_directory_uri
    1717from allmydata.interfaces import IDirectoryNode, IFileNode, IFilesystemNode, \
    1818     IImmutableFileNode, IMutableFileNode, ExistingChildError, \
    1919     NoSuchChildError, EmptyPathnameComponentError, SDMF_VERSION, MDMF_VERSION
    class DirectoryNodeHandler(RenderMixin, rend.Page, ReplaceMeMixin): 
    169169            return DirectoryReadonlyURI(ctx, self.node)
    170170        if t == 'rename-form':
    171171            return RenameForm(self.node)
     172        if t == 'move-form':
     173            return MoveForm(self.node)
    172174
    173175        raise WebError("GET directory: bad t=%s" % t)
    174176
    class DirectoryNodeHandler(RenderMixin, rend.Page, ReplaceMeMixin): 
    213215            d = self._POST_unlink(req)
    214216        elif t == "rename":
    215217            d = self._POST_rename(req)
     218        elif t == "move":
     219            d = self._POST_move(req)
    216220        elif t == "check":
    217221            d = self._POST_check(req)
    218222        elif t == "start-deep-check":
    class DirectoryNodeHandler(RenderMixin, rend.Page, ReplaceMeMixin): 
    418422        d.addCallback(lambda res: "thing renamed")
    419423        return d
    420424
     425    def _POST_move(self, req):
     426        charset = get_arg(req, "_charset", "utf-8")
     427        from_name = get_arg(req, "from_name")
     428        if from_name is not None:
     429            from_name = from_name.strip()
     430            from_name = from_name.decode(charset)
     431            assert isinstance(from_name, unicode)
     432        to_name = get_arg(req, "to_name")
     433        if to_name is not None:
     434            to_name = to_name.strip()
     435            to_name = to_name.decode(charset)
     436            assert isinstance(to_name, unicode)
     437        if not to_name:
     438            to_name = from_name
     439        to_dir = get_arg(req, "to_dir")
     440        if to_dir is not None:
     441            to_dir = to_dir.strip()
     442            to_dir = to_dir.decode(charset)
     443            assert isinstance(to_dir, unicode)
     444        if not from_name or not to_dir:
     445            raise WebError("move requires from_name and to_dir")
     446        replace = boolean_of_arg(get_arg(req, "replace", "true"))
     447
     448        # allow from_name to contain slashes, so they can fix names that
     449        # were accidentally created with them. But disallow them in to_name
     450        # (if it's specified), to discourage the practice.
     451        if to_name and "/" in to_name:
     452            raise WebError("to_name= may not contain a slash", http.BAD_REQUEST)
     453
     454        d = self.node.has_child(to_dir.split('/')[0])
     455        def get_target_node(isname):
     456            if isname or not is_writeable_directory_uri(str(to_dir)):
     457                return self.node.get_child_at_path(to_dir)
     458            else:
     459                return self.client.create_node_from_uri(str(to_dir))
     460        d.addCallback(get_target_node)
     461        def is_target_node_usable(target_node):
     462            if not IDirectoryNode.providedBy(target_node):
     463                raise WebError("to_dir is not a usable directory", http.GONE)
     464            return target_node
     465        d.addCallback(is_target_node_usable)
     466        d.addCallback(lambda new_parent: self.node.move_child_to(
     467                      from_name, new_parent, to_name, replace))
     468        d.addCallback(lambda res: "thing moved")
     469        return d
     470
    421471    def _maybe_literal(self, res, Results_Class):
    422472        if res:
    423473            return Results_Class(self.client, res)
    class DirectoryAsHTML(rend.Page): 
    662712        if self.node.is_unknown() or self.node.is_readonly():
    663713            unlink = "-"
    664714            rename = "-"
     715            move = "-"
    665716        else:
    666717            # this creates a button which will cause our _POST_unlink method
    667718            # to be invoked, which unlinks the file and then redirects the
    class DirectoryAsHTML(rend.Page): 
    680731                T.input(type='submit', value='rename', name="rename"),
    681732                ]
    682733
     734            move = T.form(action=here, method="get")[
     735                T.input(type='hidden', name='t', value='move-form'),
     736                T.input(type='hidden', name='name', value=name),
     737                T.input(type='hidden', name='when_done', value="."),
     738                T.input(type='submit', value='move', name="move"),
     739                ]
     740
    683741        ctx.fillSlots("unlink", unlink)
    684742        ctx.fillSlots("rename", rename)
     743        ctx.fillSlots("move", move)
    685744
    686745        times = []
    687746        linkcrtime = metadata.get('tahoe', {}).get("linkcrtime")
    class RenameForm(rend.Page): 
    9431002        ctx.tag.attributes['value'] = name
    9441003        return ctx.tag
    9451004
     1005class MoveForm(rend.Page):
     1006    addSlash = True
     1007    docFactory = getxmlfile("move-form.xhtml")
     1008
     1009    def render_title(self, ctx, data):
     1010        return ctx.tag["Directory SI=%s" % abbreviated_dirnode(self.original)]
     1011
     1012    def render_header(self, ctx, data):
     1013        header = ["Move "
     1014                  "from directory SI=%s" % abbreviated_dirnode(self.original),
     1015                  ]
     1016
     1017        if self.original.is_readonly():
     1018            header.append(" (readonly!)")
     1019        header.append(":")
     1020        return ctx.tag[header]
     1021
     1022    def render_when_done(self, ctx, data):
     1023        return T.input(type="hidden", name="when_done", value=".")
     1024
     1025    def render_get_name(self, ctx, data):
     1026        req = IRequest(ctx)
     1027        name = get_arg(req, "name", "")
     1028        ctx.tag.attributes['value'] = name
     1029        return ctx.tag
     1030
    9461031
    9471032class ManifestResults(rend.Page, ReloadMixin):
    9481033    docFactory = getxmlfile("manifest.xhtml")
  • src/allmydata/web/directory.xhtml

    diff --git a/src/allmydata/web/directory.xhtml b/src/allmydata/web/directory.xhtml
    index 4875738..1df38fe 100644
    a b  
    3333      <td><n:slot name="times"/></td>
    3434      <td><n:slot name="unlink"/></td>
    3535      <td><n:slot name="rename"/></td>
     36      <td><n:slot name="move"/></td>
    3637      <td><n:slot name="info"/></td>
    3738    </tr>
    3839
  • new file src/allmydata/web/move-form.xhtml

    diff --git a/src/allmydata/web/move-form.xhtml b/src/allmydata/web/move-form.xhtml
    new file mode 100644
    index 0000000..0460add
    - +  
     1<html xmlns:n="http://nevow.com/ns/nevow/0.1">
     2  <head>
     3    <title n:render="title"></title>
     4    <link href="/tahoe_css" rel="stylesheet" type="text/css"/>
     5    <link href="/webform_css" rel="stylesheet" type="text/css"/>
     6    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
     7  </head>
     8
     9<body>
     10
     11<h2 n:render="header" />
     12
     13<div class="freeform-form">
     14    <form action="." method="post" enctype="multipart/form-data">
     15        <fieldset>
     16            <legend class="freeform-form-label">Rename child</legend>
     17            <input type="hidden" name="t" value="move" />
     18            <input n:render="when_done" />
     19
     20            Move child:
     21            <input type="text" name="from_name" readonly="true" n:render="get_name" />
     22            to
     23            <input type="text" name="to_dir" /><br />
     24            New name?
     25            <input type="text" name="to_name" />
     26            <input type="submit" value="move" />
     27        </fieldset>
     28    </form>
     29</div>
     30
     31</body></html>