diff --git a/CHANGES.rst b/CHANGES.rst
index 63ee669..0dbed75 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -4,6 +4,7 @@ Changelog
2.1.0 (unreleased)
------------------
+- #22 Allow to move containers
- #21 Allow storage contents to be deactivated
- #20 Added uninstall profile
- #19 Improved storage listing and structuring with positions
diff --git a/src/senaite/storage/__init__.py b/src/senaite/storage/__init__.py
index 60d71cf..93eeace 100644
--- a/src/senaite/storage/__init__.py
+++ b/src/senaite/storage/__init__.py
@@ -39,6 +39,7 @@
# along the path used to access the module. Thus, all the modules from the path
# passed in to `allow_module` will be available.
allow_module('senaite.storage.workflow.samplescontainer.guards')
+allow_module('senaite.storage.workflow.storage.guards')
# Defining a Message Factory for when this product is internationalized.
diff --git a/src/senaite/storage/browser/container/configure.zcml b/src/senaite/storage/browser/container/configure.zcml
index 21fb7fc..689219f 100644
--- a/src/senaite/storage/browser/container/configure.zcml
+++ b/src/senaite/storage/browser/container/configure.zcml
@@ -42,4 +42,14 @@
permission="senaite.core.permissions.ManageAnalysisRequests"
layer="senaite.storage.interfaces.ISenaiteStorageLayer" />
+
+
+
diff --git a/src/senaite/storage/browser/container/move_container.py b/src/senaite/storage/browser/container/move_container.py
new file mode 100644
index 0000000..e30bec3
--- /dev/null
+++ b/src/senaite/storage/browser/container/move_container.py
@@ -0,0 +1,170 @@
+# -*- coding: utf-8 -*-
+
+from bika.lims import api
+from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
+from senaite.storage import logger
+from senaite.storage import senaiteMessageFactory as _
+from senaite.storage.api import get_parents
+from senaite.storage.browser import BaseView
+from senaite.storage.catalog import SENAITE_STORAGE_CATALOG
+from senaite.storage.interfaces import IStorageFacility
+from senaite.storage.interfaces import IStorageContainer
+from senaite.storage.interfaces import IStorageSamplesContainer
+
+
+class MoveContainerView(BaseView):
+ """Allows to move containers in other facilities/positions/containers
+ """
+ template = ViewPageTemplateFile("templates/move_container.pt")
+ action = "storage_move_container"
+
+ def __init__(self, context, request):
+ super(MoveContainerView, self).__init__(context, request)
+ self.context = context
+ self.request = request
+ self.portal = api.get_portal()
+ self.back_url = "{}/senaite_storage".format(api.get_url(self.portal))
+
+ def __call__(self):
+ form = self.request.form
+
+ # Form submit toggle
+ form_submitted = form.get("submitted", False)
+ form_move = form.get("button_move", False)
+ form_cancel = form.get("button_cancel", False)
+
+ # No items selected
+ if not self.get_objects():
+ return self.redirect(
+ message=_("No items selected"), level="warning")
+
+ # Handle store
+ if form_submitted and form_move:
+ logger.info("*** MOVE CONTAINER ***")
+ move = form.get("move", {})
+ for src, dest in move.items():
+ self.move_container(src, dest)
+ return self.redirect()
+
+ # Handle cancel
+ if form_submitted and form_cancel:
+ return self.redirect(message=_("Container moving cancelled"))
+
+ return self.template()
+
+ def get_objects(self):
+ """Get the objects to be moved
+ """
+ # fetch objects from request
+ objs = self.get_objects_from_request()
+ if objs:
+ return objs
+ if IStorageSamplesContainer.providedBy(self.context):
+ return [self.context]
+ if IStorageContainer.providedBy(self.context):
+ return [self.context]
+ return None
+
+ def add_status_message(self, message, level="info"):
+ """Set a portal status message
+ """
+ return self.context.plone_utils.addPortalMessage(message, level)
+
+ def move_container(self, src, dest):
+ """move container from source to destination
+ """
+ source = api.get_object(src)
+ destination = api.get_object(dest)
+ parent = api.get_parent(source)
+
+ if destination == parent:
+ message = _(u"Container {} is already located in destination path!"
+ .format(self.get_container_path(source)))
+ self.add_status_message(message, level="warning")
+ return False
+ cb = parent.manage_cutObjects(ids=[api.get_id(source)])
+ destination.manage_pasteObjects(cb_copy_data=cb)
+
+ message = _(u"Moved container {} → {}".format(
+ self.get_title(source), self.get_container_path(destination)))
+ self.add_status_message(message, level="info")
+ return True
+
+ def get_parents_for(self, container):
+ """Get the parent objects for the container
+ """
+ if IStorageFacility.providedBy(container):
+ return [container]
+
+ def predicate(obj):
+ return IStorageFacility.providedBy(obj)
+
+ return get_parents(container, predicate=predicate)
+
+ def get_title(self, obj):
+ """Return the object title as unicode
+ """
+ title = api.get_title(obj)
+ return api.safe_unicode(title)
+
+ def get_container_path(self, container):
+ """Return the facility container path
+ """
+ parents = list(reversed(self.get_parents_for(container)))
+ parents.append(container)
+ return " / ".join(map(self.get_title, parents))
+
+ def get_movable_containers(self):
+ """Get movable containers
+
+ NOTE: contained containers will be omitted
+ """
+ movable_containers = []
+ containers = self.get_objects()
+ for container in containers:
+ parents = self.get_parents_for(container)
+ if set(parents).intersection(containers):
+ continue
+ movable_containers.append(container)
+ return movable_containers
+
+ def get_move_targets_for(self, container):
+ """Get move targets for the given container
+ """
+ targets = []
+ container_path = api.get_path(container)
+ target_types = [
+ "StorageFacility",
+ "StoragePosition",
+ "StorageContainer",
+ ]
+ # sample containers can only be moved inside containers
+ if IStorageSamplesContainer.providedBy(container):
+ target_types = ["StorageContainer"]
+ query = {
+ "portal_type": target_types,
+ "review_state": "active"
+ }
+ brains = api.search(query, SENAITE_STORAGE_CATALOG)
+ for brain in brains:
+ path = api.get_path(brain)
+ if path.startswith(container_path):
+ continue
+ targets.append(api.get_object(brain))
+ return targets
+
+ def get_container_data(self):
+ """Returns a list of containers that can be moved
+
+ """
+ for obj in self.get_movable_containers():
+ obj = api.get_object(obj)
+ yield {
+ "obj": obj,
+ "id": api.get_id(obj),
+ "uid": api.get_uid(obj),
+ "title": self.get_title(obj),
+ "path": self.get_container_path(obj),
+ "url": api.get_url(obj),
+ "targets": self.get_move_targets_for(obj),
+ }
diff --git a/src/senaite/storage/browser/container/templates/move_container.pt b/src/senaite/storage/browser/container/templates/move_container.pt
new file mode 100644
index 0000000..0c3056c
--- /dev/null
+++ b/src/senaite/storage/browser/container/templates/move_container.pt
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+
+
+ Move Containers
+
+
+
+
+
+
+
+ ← Back
+
+
+
+
+
+
+
+
+
+
diff --git a/src/senaite/storage/browser/workflow/configure.zcml b/src/senaite/storage/browser/workflow/configure.zcml
index 60a4648..ae98991 100644
--- a/src/senaite/storage/browser/workflow/configure.zcml
+++ b/src/senaite/storage/browser/workflow/configure.zcml
@@ -23,4 +23,13 @@
provides="bika.lims.interfaces.IWorkflowActionAdapter"
permission="zope.Public" />
+
+
+
diff --git a/src/senaite/storage/browser/workflow/storagecontainer.py b/src/senaite/storage/browser/workflow/storagecontainer.py
index 3918b1e..771de63 100644
--- a/src/senaite/storage/browser/workflow/storagecontainer.py
+++ b/src/senaite/storage/browser/workflow/storagecontainer.py
@@ -19,9 +19,10 @@
# Some rights reserved, see README and LICENSE.
from bika.lims import api
-from senaite.storage.interfaces import IStorageSamplesContainer
from bika.lims.browser.workflow import RequestContextAware
from bika.lims.interfaces import IWorkflowActionUIDsAdapter
+from senaite.storage.interfaces import IStorageLayoutContainer
+from senaite.storage.interfaces import IStorageSamplesContainer
from zope.interface import implementer
@@ -41,3 +42,21 @@ def __call__(self, action, uids):
url = "{}/storage_store_container?uids={}".format(
self.back_url, ",".join(container_uids))
return self.redirect(redirect_url=url)
+
+
+@implementer(IWorkflowActionUIDsAdapter)
+class WorkflowActionMoveContainerAdapter(RequestContextAware):
+ """Adapter in charge of "move container" action
+ """
+
+ def __call__(self, action, uids):
+ """Redirects the user to the Samples selector view
+ """
+ # filter out UIDs not belonging to sample containers
+ objs = map(api.get_object, uids)
+ containers = filter(
+ lambda o: IStorageLayoutContainer.providedBy(o), objs)
+ container_uids = map(api.get_uid, containers)
+ url = "{}/storage_move_container?uids={}".format(
+ self.back_url, ",".join(container_uids))
+ return self.redirect(redirect_url=url)
diff --git a/src/senaite/storage/permissions.py b/src/senaite/storage/permissions.py
index de24466..c6c68a4 100644
--- a/src/senaite/storage/permissions.py
+++ b/src/senaite/storage/permissions.py
@@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
+TransitionMoveContainer = "senaite.storage: Transition: Move Container"
+
TransitionStoreSample = "senaite.storage: Transition: Store Sample"
TransitionRecoverSample = "senaite.storage: Transition: Recover Sample"
diff --git a/src/senaite/storage/permissions.zcml b/src/senaite/storage/permissions.zcml
index ebceee2..b6486c1 100644
--- a/src/senaite/storage/permissions.zcml
+++ b/src/senaite/storage/permissions.zcml
@@ -2,6 +2,9 @@
xmlns="http://namespaces.zope.org/zope"
i18n_domain="senaite.core">
+
+
+
diff --git a/src/senaite/storage/profiles/default/rolemap.xml b/src/senaite/storage/profiles/default/rolemap.xml
index 0fa8c61..2314252 100644
--- a/src/senaite/storage/profiles/default/rolemap.xml
+++ b/src/senaite/storage/profiles/default/rolemap.xml
@@ -3,6 +3,12 @@
+
+
+
+
+
+
diff --git a/src/senaite/storage/profiles/default/workflows/senaite_storage_default_workflow/definition.xml b/src/senaite/storage/profiles/default/workflows/senaite_storage_default_workflow/definition.xml
index 68aa5a9..590f6dc 100644
--- a/src/senaite/storage/profiles/default/workflows/senaite_storage_default_workflow/definition.xml
+++ b/src/senaite/storage/profiles/default/workflows/senaite_storage_default_workflow/definition.xml
@@ -34,6 +34,7 @@
+
@@ -50,6 +51,7 @@
+
@@ -82,9 +84,9 @@
- Add samples
+ Add samples
senaite.storage: Transition: Add Samples
python:here.guard_add_samples()
@@ -93,7 +95,7 @@
Recover samples
@@ -102,6 +104,17 @@
+
+
+ Move container
+
+ senaite.storage: Transition: Move Container
+ python:here.guard_move_container()
+
+
+
Previous transition
diff --git a/src/senaite/storage/workflow/storage/guards.py b/src/senaite/storage/workflow/storage/guards.py
new file mode 100644
index 0000000..981f558
--- /dev/null
+++ b/src/senaite/storage/workflow/storage/guards.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+
+from AccessControl.SecurityInfo import ModuleSecurityInfo
+from bika.lims import api
+from senaite.storage.interfaces import IStorageContainer
+from senaite.storage.interfaces import IStorageSamplesContainer
+
+security = ModuleSecurityInfo(__name__)
+
+
+@security.public
+def guard_move_container(container):
+ """Guard for move container
+ """
+ if not api.is_active(container):
+ return False
+ if IStorageContainer.providedBy(container):
+ return True
+ if IStorageSamplesContainer.providedBy(container):
+ return True
+ return False
diff --git a/src/senaite/storage/zope_scripts/guard_move_container.py b/src/senaite/storage/zope_scripts/guard_move_container.py
new file mode 100644
index 0000000..016ccf7
--- /dev/null
+++ b/src/senaite/storage/zope_scripts/guard_move_container.py
@@ -0,0 +1,12 @@
+## Script (Python) "guard_move_container"
+##bind container=container
+##bind context=context
+##bind namespace=
+##bind script=script
+##bind subpath=traverse_subpath
+##parameters=
+##title=
+##
+
+from senaite.storage.workflow.storage.guards import guard_move_container
+return guard_move_container(context)