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

+
+ + + +
+ +
+
+ + + + + +
+ +
+ Move container + + + to +
+ +
+ + +
+
+ + +
+ + + + +
+ +
+
+ +
+
+ + 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)