diff --git a/docs/changelog.rst b/docs/changelog.rst
index 47b9a16..678c16c 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -4,6 +4,7 @@ Changelog
2.0.0 (Unreleased)
------------------
+- #35 Rejection inbound samples
- #32 Fix build test after adding default contact
- #31 Add default contact for ExternalLaboratory and InboundSampleShipment content
- First version
diff --git a/src/senaite/referral/browser/inbound/configure.zcml b/src/senaite/referral/browser/inbound/configure.zcml
index e9ba0cf..86082b2 100644
--- a/src/senaite/referral/browser/inbound/configure.zcml
+++ b/src/senaite/referral/browser/inbound/configure.zcml
@@ -3,6 +3,9 @@
xmlns:browser="http://namespaces.zope.org/browser"
i18n_domain="senaite.referral">
+
+
+
-
-
-
diff --git a/src/senaite/referral/browser/inbound/samples/__init__.py b/src/senaite/referral/browser/inbound/samples/__init__.py
new file mode 100644
index 0000000..b7329f3
--- /dev/null
+++ b/src/senaite/referral/browser/inbound/samples/__init__.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+#
+# This file is part of SENAITE.REFERRAL.
+#
+# SENAITE.REFERRAL is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, version 2.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program; if not, write to the Free Software Foundation, Inc., 51
+# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+# Copyright 2021-2025 by it's authors.
+# Some rights reserved, see README and LICENSE.
diff --git a/src/senaite/referral/browser/inbound/samples/configure.zcml b/src/senaite/referral/browser/inbound/samples/configure.zcml
new file mode 100644
index 0000000..954404d
--- /dev/null
+++ b/src/senaite/referral/browser/inbound/samples/configure.zcml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/senaite/referral/browser/inbound/samples/rejection/__init__.py b/src/senaite/referral/browser/inbound/samples/rejection/__init__.py
new file mode 100644
index 0000000..b7329f3
--- /dev/null
+++ b/src/senaite/referral/browser/inbound/samples/rejection/__init__.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+#
+# This file is part of SENAITE.REFERRAL.
+#
+# SENAITE.REFERRAL is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, version 2.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program; if not, write to the Free Software Foundation, Inc., 51
+# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+# Copyright 2021-2025 by it's authors.
+# Some rights reserved, see README and LICENSE.
diff --git a/src/senaite/referral/browser/inbound/samples/rejection/configure.zcml b/src/senaite/referral/browser/inbound/samples/rejection/configure.zcml
new file mode 100644
index 0000000..2ad6fba
--- /dev/null
+++ b/src/senaite/referral/browser/inbound/samples/rejection/configure.zcml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/src/senaite/referral/browser/inbound/samples/rejection/reject_inbound_samples.py b/src/senaite/referral/browser/inbound/samples/rejection/reject_inbound_samples.py
new file mode 100644
index 0000000..8339e37
--- /dev/null
+++ b/src/senaite/referral/browser/inbound/samples/rejection/reject_inbound_samples.py
@@ -0,0 +1,151 @@
+# -*- coding: utf-8 -*-
+#
+# This file is part of SENAITE.REFERRAL.
+#
+# SENAITE.REFERRAL is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, version 2.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program; if not, write to the Free Software Foundation, Inc., 51
+# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+# Copyright 2021-2025 by it's authors.
+# Some rights reserved, see README and LICENSE.
+
+import six
+from bika.lims import api
+from bika.lims import senaiteMessageFactory as _
+from plone.memoize import view
+from Products.Five.browser import BrowserView
+from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
+from senaite.core import logger
+from senaite.core.api.dtime import to_localized_time
+from senaite.referral.catalog import INBOUND_SAMPLE_CATALOG
+
+
+class RejectInboundSamplesView(BrowserView):
+ """View that renders the Inbound Samples rejection view
+ """
+ template = ViewPageTemplateFile("templates/reject_inbound_samples.pt")
+
+ def __init__(self, context, request):
+ super(RejectInboundSamplesView, self).__init__(context, request)
+ self.context = context
+ self.request = request
+ self.back_url = api.get_url(self.context.getInboundShipment())
+
+ def __call__(self):
+ form = self.request.form
+
+ # Form submit toggle
+ form_submitted = form.get("submitted", False)
+
+ # Buttons
+ form_continue = form.get("button_continue", False)
+ form_cancel = form.get("button_cancel", False)
+
+ # Get the objects from request
+ inbound_samples = self.get_inbound_samples_from_request()
+
+ # No Inbound Samples selected
+ if not inbound_samples:
+ return self.redirect(message=_("No items selected"),
+ level="warning")
+
+ # Handle rejection
+ if form_submitted and form_continue:
+ logger.info("*** REJECT INBOUND SAMPLES ***")
+ processed = []
+ for inbound_sample in form.get("inbound_samples", []):
+ inbound_sample_uid = inbound_sample.get("uid", "")
+ reasons = inbound_sample.get("reasons", [])
+ other = inbound_sample.get("other_reasons", "")
+ if not inbound_sample_uid:
+ continue
+
+ # Omit if no rejection reason specified
+ if not any([reasons, other]):
+ continue
+
+ obj = api.get_object_by_uid(inbound_sample_uid)
+
+ obj.setSelectedRejectionReasons(reasons)
+ obj.setOtherRejectionReasons(other)
+
+ # Reject the sample
+ processed.append(obj)
+
+ if not processed:
+ return self.redirect(message=_("No samples were rejected"))
+
+ message = _("Rejected {} samples: {}").format(
+ len(processed), ", ".join(map(api.get_id, processed)))
+ return self.redirect(message=message)
+
+ # Handle cancel
+ if form_submitted and form_cancel:
+ logger.info("*** CANCEL REJECTION ***")
+ return self.redirect(message=_("Rejection cancelled"))
+
+ return self.template()
+
+ @view.memoize
+ def get_inbound_samples_from_request(self):
+ """Returns a list of objects coming from the "uids" request parameter
+ """
+ uids = self.request.form.get("uids", "")
+ if isinstance(uids, six.string_types):
+ uids = uids.split(",")
+
+ uids = list(set(uids))
+ if not uids:
+ return []
+
+ inbound_samples = []
+ query = dict(portal_type="InboundSample", UID=uids)
+ for brain in api.search(query, INBOUND_SAMPLE_CATALOG):
+ inbound_sample = api.get_object(brain)
+ inbound_samples.append(inbound_sample)
+ return inbound_samples
+
+ @view.memoize
+ def get_rejection_reasons(self):
+ """Returns the list of available rejection reasons
+ """
+ return api.get_setup().getRejectionReasonsItems()
+
+ def get_inbound_samples_data(self):
+ """Returns a list of Inbound Samples data (dictionary)
+ """
+ for obj in self.get_inbound_samples_from_request():
+ yield {
+ "obj": obj,
+ "id": api.get_id(obj),
+ "uid": api.get_uid(obj),
+ "title": obj.Title(),
+ "sample_type": obj.getSampleType(),
+ "analyses": obj.getAnalyses(),
+ "date": to_localized_time(
+ obj.getDateSampled(), long_format=True
+ ),
+ }
+
+ def redirect(self, redirect_url=None, message=None, level="info"):
+ """Redirect with a message
+ """
+ if redirect_url is None:
+ redirect_url = self.back_url
+ if message is not None:
+ self.add_status_message(message, level)
+ return self.request.response.redirect(redirect_url)
+
+ def add_status_message(self, message, level="info"):
+ """Set a portal status message
+ """
+ return self.context.plone_utils.addPortalMessage(message, level)
diff --git a/src/senaite/referral/browser/inbound/samples/rejection/templates/reject_inbound_samples.pt b/src/senaite/referral/browser/inbound/samples/rejection/templates/reject_inbound_samples.pt
new file mode 100644
index 0000000..6f5298c
--- /dev/null
+++ b/src/senaite/referral/browser/inbound/samples/rejection/templates/reject_inbound_samples.pt
@@ -0,0 +1,131 @@
+
+
+
+
+
+
diff --git a/src/senaite/referral/browser/inbound/samples.py b/src/senaite/referral/browser/inbound/samples/view.py
similarity index 98%
rename from src/senaite/referral/browser/inbound/samples.py
rename to src/senaite/referral/browser/inbound/samples/view.py
index c3947d5..6fe0c06 100644
--- a/src/senaite/referral/browser/inbound/samples.py
+++ b/src/senaite/referral/browser/inbound/samples/view.py
@@ -15,7 +15,7 @@
# this program; if not, write to the Free Software Foundation, Inc., 51
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#
-# Copyright 2021-2022 by it's authors.
+# Copyright 2021-2025 by it's authors.
# Some rights reserved, see README and LICENSE.
import collections
@@ -115,15 +115,16 @@ def default_review_states(self):
},
"custom_transitions": [print_stickers],
"columns": self.columns.keys(),
- }, {
+ },
+ {
"id": "rejected",
"title": _("Rejected"),
"contentFilter": {
"review_state": "rejected",
},
- "custom_transitions": [],
"columns": self.columns.keys(),
- }, {
+ },
+ {
"id": "all",
"title": _("All"),
"contentFilter": {},
diff --git a/src/senaite/referral/browser/viewlets/configure.zcml b/src/senaite/referral/browser/viewlets/configure.zcml
index 9b1edc9..932faaf 100644
--- a/src/senaite/referral/browser/viewlets/configure.zcml
+++ b/src/senaite/referral/browser/viewlets/configure.zcml
@@ -52,4 +52,15 @@
permission="zope2.View"
layer="senaite.referral.interfaces.ISenaiteReferralLayer" />
+
+
+
+
diff --git a/src/senaite/referral/browser/viewlets/inboundsample.py b/src/senaite/referral/browser/viewlets/inboundsample.py
new file mode 100644
index 0000000..608c3de
--- /dev/null
+++ b/src/senaite/referral/browser/viewlets/inboundsample.py
@@ -0,0 +1,61 @@
+# -*- coding: utf-8 -*-
+#
+# This file is part of SENAITE.REFERRAL.
+#
+# SENAITE.REFERRAL is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, version 2.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program; if not, write to the Free Software Foundation, Inc., 51
+# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+# Copyright 2021-2025 by it's authors.
+# Some rights reserved, see README and LICENSE.
+
+from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
+from bika.lims import api
+from plone.app.layout.viewlets import ViewletBase
+from plone.memoize import view
+from senaite.referral import check_installed
+from senaite.referral.catalog import INBOUND_SAMPLE_CATALOG
+
+
+class RejectedInboundSamplesViewlet(ViewletBase):
+ """Current Inbound Samples were rejected. Display the reasons
+ """
+ template = ViewPageTemplateFile("templates/rejected_inbound_samples_viewlet.pt") # noqa: E501
+
+ @check_installed(False)
+ def is_visible(self):
+ """Returns whether the viewlet must be visible or not
+ """
+ rejected_inbound_samples = self.get_rejected_inbound_samples()
+ if not rejected_inbound_samples:
+ return False
+
+ return True
+
+ def get_rejected_inbound_samples(self):
+ """Returns the rejected inbound samples
+ """
+ query = dict(
+ portal_type="InboundSample",
+ path=dict(query=api.get_path(self.context), level=0),
+ review_state="rejected")
+ brains = api.search(query, INBOUND_SAMPLE_CATALOG)
+ return [api.get_object(brain) for brain in brains]
+
+ @view.memoize
+ def has_reasons(self, inbound_sample):
+ """Returns whether the sample has reasons or not
+ """
+ return (
+ inbound_sample.getSelectedRejectionReasons() or
+ inbound_sample.getOtherRejectionReasons()
+ )
diff --git a/src/senaite/referral/browser/viewlets/templates/rejected_inbound_samples_viewlet.pt b/src/senaite/referral/browser/viewlets/templates/rejected_inbound_samples_viewlet.pt
new file mode 100644
index 0000000..30946f2
--- /dev/null
+++ b/src/senaite/referral/browser/viewlets/templates/rejected_inbound_samples_viewlet.pt
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+ Rejected Inbound Samples
+
+
+ Sample has been rejected due to the following reasons:
+ Sample has been rejected
+