diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 0000000..d03c05a --- /dev/null +++ b/.github/workflows/build-and-test.yml @@ -0,0 +1,30 @@ +name: build and test senaite.ast +on: + - push + - pull_request +env: + PLONE_VERSION: "5.2" +jobs: + build-and-test: + runs-on: 'ubuntu-20.04' + container: + image: python:2.7.18-buster + steps: + - uses: actions/checkout@v3 + - name: cache eggs + uses: actions/cache@v3 + with: + key: eggs-cache-${{ hashFiles('buildout.cfg', 'requirements.txt') }} + path: | + eggs/ + - name: install + run: | + pip install virtualenv + virtualenv -p `which python` . + bin/pip install --upgrade pip + bin/pip install -r requirements.txt + bin/buildout -N -t 3 annotate + bin/buildout -N -t 3 + - name: test + run: | + bin/test -s senaite.ast.tests diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..080b0a6 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,15 @@ +# Read the Docs configuration file for Sphinx projects +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the OS, Python version and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.12" + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/conf.py diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index f97fdaa..0000000 --- a/.travis.yml +++ /dev/null @@ -1,21 +0,0 @@ -version: ~> 1.0 -language: python -os: linux -dist: xenial -cache: - pip: true - directories: - - eggs -matrix: - fast_finish: true - include: - - python: "2.7" - env: PLONE_VERSION="5.2" -before_install: - - virtualenv -p `which python` . - - bin/pip install -r requirements.txt - - bin/buildout -N -t 3 annotate -install: - - bin/buildout -N -t 3 -script: - - bin/test -s senaite.ast diff --git a/README.rst b/README.rst index 1e8c076..192ab42 100644 --- a/README.rst +++ b/README.rst @@ -4,8 +4,8 @@ Antibiotic Sensitivity Testing (AST) for SENAITE .. image:: https://img.shields.io/pypi/v/senaite.ast.svg?style=flat-square :target: https://pypi.python.org/pypi/senaite.ast -.. image:: https://img.shields.io/travis/com/senaite/senaite.ast/master.svg?style=flat-square - :target: https://app.travis-ci.com/github/senaite/senaite.ast +.. image:: https://img.shields.io/github/actions/workflow/status/senaite/senaite.ast/build-and-test.yml?branch=2.x + :target: https://github.com/senaite/senaite.ast/actions/workflows/build-and-test.yml?query=branch:2.x .. image:: https://readthedocs.org/projects/pip/badge/ :target: https://senaiteast.readthedocs.org diff --git a/buildout.cfg b/buildout.cfg index 3f7ef16..754c187 100644 --- a/buildout.cfg +++ b/buildout.cfg @@ -1,105 +1,7 @@ [buildout] -index = https://pypi.org/simple/ -extends = https://dist.plone.org/release/5.2-latest/versions.cfg -find-links = - https://dist.plone.org/release/5.2-latest/ - https://dist.plone.org/thirdparty/ - -parts = - instance - test - omelette - i18ndude - zopepy - update_translations - write_code_headers - -eggs = - senaite.core - senaite.app.listing - senaite.app.spotlight - senaite.app.supermodel - senaite.impress - senaite.jsonapi - senaite.lims - senaite.abx - senaite.microorganism - senaite.ast - plone.reload - Products.PrintingMailHost - -extensions = mr.developer - +extends = https://raw.githubusercontent.com/senaite/senaite.core/2.x/buildout.base.cfg package-name = senaite.ast -versions = versions -show-picked-versions = true - -plone-user = admin:admin - -develop = . -sources = sources -auto-checkout = * - [sources] -senaite.core = git https://github.com/senaite/senaite.core.git branch=2.x -senaite.app.listing = git https://github.com/senaite/senaite.app.listing.git branch=2.x -senaite.app.spotlight = git https://github.com/senaite/senaite.app.spotlight.git branch=2.x -senaite.app.supermodel = git https://github.com/senaite/senaite.app.supermodel.git branch=2.x -senaite.impress = git https://github.com/senaite/senaite.impress.git branch=2.x -senaite.jsonapi = git https://github.com/senaite/senaite.jsonapi.git branch=2.x -senaite.lims = git https://github.com/senaite/senaite.lims.git branch=2.x senaite.abx = git https://github.com/senaite/senaite.abx.git senaite.microorganism = git https://github.com/senaite/senaite.microorganism.git - -[instance] -recipe = plone.recipe.zope2instance -http-address = 127.0.0.1:8080 -user = ${buildout:plone-user} -wsgi = on -eggs = - Plone - plone.app.upgrade - ${buildout:package-name} - ${buildout:eggs} -deprecation-warnings = on -environment-vars = - zope_i18n_compile_mo_files true -zcml = - -[i18ndude] -unzip = true -recipe = zc.recipe.egg -eggs = i18ndude - -[update_translations] -recipe = collective.recipe.template -output = ${buildout:directory}/bin/update_translations -input = ${buildout:directory}/templates/update_translations.in -mode = 755 - -[write_code_headers] -recipe = collective.recipe.template -output = ${buildout:directory}/bin/write_code_headers -input = ${buildout:directory}/templates/write_code_headers.py.in -mode = 755 - -[test] -recipe = zc.recipe.testrunner -defaults = ['--auto-color', '--auto-progress'] -eggs = - senaite.ast [test] - -[omelette] -recipe = collective.recipe.omelette -eggs = ${buildout:eggs} - -[zopepy] -recipe = zc.recipe.egg -eggs = ${instance:eggs} -interpreter = zopepy -scripts = zopepy - -[versions] -setuptools = -zc.buildout = diff --git a/docs/changelog.rst b/docs/changelog.rst index 9626b19..b341443 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,14 @@ Changelog 1.2.0 (unreleased) ------------------ +- #37 Compatibility with core#2567 (AnalysisCategory to DX) +- #37 Compatibility with core#2471 (Department to DX) +- #36 Fix user can edit ast built-in services after upgrades +- #35 Compatibility with core#2521 - AT2DX ARTemplate/SampleTemplate +- #34 Add transition "Reject antibiotics" +- #33 Display the Antibiotic Sensitivity section only when necessary +- #32 Compatibility with senaite.core#2492 (AnalysisProfile to DX) + 1.1.0 (2024-01-04) ------------------ @@ -17,6 +25,7 @@ Changelog - #21 Fix AST entry is empty when analyses categorization for sample is checked - #20 Compatibility with senaite.app.listing#87 + 1.0.0 (2022-06-18) ------------------ diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 2e10e14..1187c24 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -175,7 +175,30 @@ From this view, user can choose the tuples Microorganism-Antibiotic to be reported in results. Once the button "Save" is pressed, the value for analyses with name "Report" for all microorganisms are updated accordingly. -.. Links + +.. _RejectAntibiotics: + +Flag antibiotics as Not Tested +------------------------------ + +Quite often, laboratory manager will want to report "Not tested" for some +antibiotics and microorganisms, while keeping them in the AST panel. This can +be easily achieved with the transition "Reject antibiotics". This transition is +available for analyses that are part of a sensitivity testing panel. Once +clicked, a modal view is displayed, where user can choose the antibiotics to be +flagged as "Not Tested": + +.. image:: static/reject_antibiotics.png + :width: 640 + :alt: Reject antibiotics + +Once done, "NT" is displayed as a result for the selected antibiotics, both in +results entry and in results report: + +.. image:: static/not_tested.png + :width: 640 + :alt: Antibiotics flagged as Not Tested + .. _SENAITE LIMS: https://www.senaite.com .. _senaite.ast: https://pypi.python.org/pypi/senaite.ast diff --git a/docs/static/not_tested.png b/docs/static/not_tested.png new file mode 100644 index 0000000..99c3b0d Binary files /dev/null and b/docs/static/not_tested.png differ diff --git a/docs/static/reject_antibiotics.png b/docs/static/reject_antibiotics.png new file mode 100644 index 0000000..e56fd8f Binary files /dev/null and b/docs/static/reject_antibiotics.png differ diff --git a/requirements.txt b/requirements.txt index b65a7a0..1e1b3ab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -setuptools==42.0.2 +setuptools==44.1.1 zc.buildout==2.13.3 wheel diff --git a/src/senaite/ast/adapters/configure.zcml b/src/senaite/ast/adapters/configure.zcml index e5870ca..e342915 100644 --- a/src/senaite/ast/adapters/configure.zcml +++ b/src/senaite/ast/adapters/configure.zcml @@ -23,15 +23,15 @@ AST-like analyses are always added manually afterwards, based on the result oif the "Microorganism Identification" analysis --> - + diff --git a/src/senaite/ast/adapters/guards.py b/src/senaite/ast/adapters/guards.py index 74e7a6b..7e34a5c 100644 --- a/src/senaite/ast/adapters/guards.py +++ b/src/senaite/ast/adapters/guards.py @@ -26,6 +26,7 @@ from senaite.ast.config import DISK_CONTENT_KEY from senaite.ast.config import MIC_KEY from senaite.ast.config import ZONE_SIZE_KEY +from senaite.ast.utils import is_ast_analysis from zope.interface import implementer OPERATORS = ["<=", ">=", "<", ">"] @@ -103,6 +104,10 @@ def guard_submit(self): # Skip extrapolated antibiotics continue + if utils.is_rejected_interim(antibiotic): + # Skip rejected antibiotics + continue + if utils.is_interim_empty(antibiotic): # Cannot submit if no result return False @@ -135,3 +140,9 @@ def guard_submit(self): return False return True + + def guard_reject_antibiotics(self): + """Rejection of antibiotics is only possible for sensitivity testing + (AST) analyses + """ + return is_ast_analysis(self.context) diff --git a/src/senaite/ast/browser/configure.zcml b/src/senaite/ast/browser/configure.zcml index 7b2b524..7c010df 100644 --- a/src/senaite/ast/browser/configure.zcml +++ b/src/senaite/ast/browser/configure.zcml @@ -6,6 +6,7 @@ + diff --git a/src/senaite/ast/browser/modals/__init__.py b/src/senaite/ast/browser/modals/__init__.py new file mode 100644 index 0000000..6899a65 --- /dev/null +++ b/src/senaite/ast/browser/modals/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.AST. +# +# SENAITE.AST 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 2020-2024 by it's authors. +# Some rights reserved, see README and LICENSE. diff --git a/src/senaite/ast/browser/modals/configure.zcml b/src/senaite/ast/browser/modals/configure.zcml new file mode 100644 index 0000000..4420d95 --- /dev/null +++ b/src/senaite/ast/browser/modals/configure.zcml @@ -0,0 +1,13 @@ + + + + + + diff --git a/src/senaite/ast/browser/modals/rejectantibiotics.py b/src/senaite/ast/browser/modals/rejectantibiotics.py new file mode 100644 index 0000000..9b78334 --- /dev/null +++ b/src/senaite/ast/browser/modals/rejectantibiotics.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.AST. +# +# SENAITE.AST 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 2020-2024 by it's authors. +# Some rights reserved, see README and LICENSE. + +import itertools +from datetime import datetime + +from bika.lims import api +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from senaite.ast import logger +from senaite.ast import utils +from senaite.ast.calc import update_sensitivity_result +from senaite.ast.config import NOT_TESTED +from senaite.ast.config import REPORT_EXTRAPOLATED_KEY +from senaite.ast.config import REPORT_KEY +from senaite.ast.config import RESISTANCE_KEY +from senaite.core.api import dtime as dt +from senaite.core.browser.modals import Modal + + +class RejectAntibioticsModal(Modal): + """Modal that allows to reject antibiotics (flag them as Not Tested) + """ + + template = ViewPageTemplateFile("templates/reject_antibiotics.pt") + + def __call__(self): + if self.request.form.get("submitted", False): + self.handle_submit() + return self.template() + + @property + def analyses(self): + """Returns the analyses passed-in as UIDs through the request + """ + analyses = map(api.get_object_by_uid, self.uids) + # be sure we rely on AST analyses only + analyses = filter(utils.is_ast_analysis, analyses) + # extend with siblings (AST-like analyses from same microorganism) + siblings = map(utils.get_ast_siblings, analyses) + siblings = list(itertools.chain.from_iterable(siblings)) + analyses.extend(siblings) + # remove duplicates + analyses = set(analyses) + # exclude analyses meant for selective reporting + exclude = [REPORT_KEY, REPORT_EXTRAPOLATED_KEY] + analyses = filter(lambda an: an.getKeyword() not in exclude, analyses) + return analyses + + @property + def antibiotics(self): + """Returns a list of antibiotics with the antibiotics assigned to + the analyses passed-in as UIDs through the request, sorted by name + ascending + """ + kwargs = dict(filter_criteria=self.is_valid_antibiotic) + antibiotics = utils.get_antibiotics(self.analyses, **kwargs) + return sorted(antibiotics, key=lambda ab: api.get_title(ab)) + + def is_valid_antibiotic(self, interim_field): + """Returns whether the interim field corresponds to an antibiotic that + has not been rejected (flagged as not tested) + """ + if interim_field.get("status_rejected", False): + return False + return True + + def handle_submit(self): + """Handles the form submit. Flag the antibiotics selected in the form + as Not Tested for all analyses passed-in as UIDs through the request + """ + # get the uids that have been selected for rejection + rejected_uids = self.request.get("antibiotics") + + # flag the antibiotic as Not Tested (NT) for each analysis + for analysis in self.analyses: + self.reject_antibiotics(analysis, rejected_uids) + + def reject_antibiotics(self, analysis, antibiotics): + """Flags the antibiotics passed-in for the given analysis as Not tested + """ + interims = [] + keyword = analysis.getKeyword() + to_reject = map(api.get_uid, antibiotics) + for interim in analysis.getInterimFields(): + abx_uid = interim.get("uid") + if abx_uid in to_reject: + + # set rejected status + user_id = api.get_current_user().id + timestamp = dt.to_iso_format(datetime.now()) + interim["status_rejected"] = timestamp + interim["status_rejected_by"] = user_id + + # set the result value + self.set_not_tested_result(interim) + + interims.append(interim) + + # update the interims/antibiotics + analysis.setInterimFields(interims) + + if keyword == RESISTANCE_KEY: + # Compute all combinations of interim/antibiotic and possible + # result and generate the result options for this analysis (the + # "Result" field is never displayed and is only used for reporting) + result_options = utils.get_result_options(analysis) + analysis.setResultOptions(result_options) + + # Update the final result to be reported + update_sensitivity_result(analysis) + + def set_not_tested_result(self, interim): + """Sets the 'Not tested' result to the interim field. If the interim + has choices, it uses '0' as the value for the Not Tested. Otherwise, + sets NT as the textual result + """ + choices = utils.get_choices(interim) + if not choices: + # no choices, set string result + interim["value"] = NOT_TESTED + interim["string_result"] = True + return + + # insert a new choice ("-1", "NT") + choice = ("-1", NOT_TESTED) + if choice not in choices: + choices.insert(0, choice) + choices = ["{}:{}".format(ch[0], ch[1]) for ch in choices] + interim["choices"] = "|".join(choices) + + # assign the result value + interim["value"] = "-1" + interim["string_result"] = True diff --git a/src/senaite/ast/browser/modals/templates/reject_antibiotics.pt b/src/senaite/ast/browser/modals/templates/reject_antibiotics.pt new file mode 100644 index 0000000..061a2ce --- /dev/null +++ b/src/senaite/ast/browser/modals/templates/reject_antibiotics.pt @@ -0,0 +1,54 @@ + diff --git a/src/senaite/ast/browser/results.py b/src/senaite/ast/browser/results.py index 29002f5..493c1d6 100644 --- a/src/senaite/ast/browser/results.py +++ b/src/senaite/ast/browser/results.py @@ -24,6 +24,7 @@ from bika.lims import api from bika.lims.browser.analyses import AnalysesView from bika.lims.interfaces import IVerified +from bika.lims.utils import get_image from bika.lims.utils import get_link from plone.memoize import view from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile @@ -31,6 +32,9 @@ from senaite.ast import messageFactory as _ from senaite.ast import utils from senaite.ast.config import AST_POINT_OF_CAPTURE +from senaite.ast.config import IDENTIFICATION_KEY +from senaite.ast.utils import get_ast_analyses +from senaite.ast.i18n import translate as t from senaite.core.browser.viewlets.sampleanalyses import LabAnalysesViewlet @@ -42,9 +46,24 @@ class ASTAnalysesViewlet(LabAnalysesViewlet): capture = AST_POINT_OF_CAPTURE def available(self): - """Returns true if senaite.ast is installed + """Returns true if senaite.ast is installed and the sample contains + at least one sensitivity testing analysis or the microorganism + identification analysis is present """ - return is_installed() + if not is_installed(): + return False + + # does this sample has the identification analysis? + analyses = self.context.getAnalyses(getKeyword=IDENTIFICATION_KEY) + if analyses: + return True + + # does this have sensitivity testing analyses? + ast_analyses = get_ast_analyses(self.context) + if ast_analyses: + return True + + return False class ManageResultsView(AnalysesView): @@ -166,6 +185,9 @@ def folder_interim_fields(self, obj, item): # This interim will be displayed as readonly mode, display text text = utils.get_interim_text(interim_field, default="") + if interim_field.get("status_rejected", False): + icon = get_image('warning.png', title=t(_("Not tested"))) + text = "{}{}".format(text, icon) item["replace"][keyword] = text or " " def folderitems(self): diff --git a/src/senaite/ast/calc.py b/src/senaite/ast/calc.py index eb236f6..04fcfa9 100644 --- a/src/senaite/ast/calc.py +++ b/src/senaite/ast/calc.py @@ -97,7 +97,7 @@ def calc_sensitivity_categories(analysis): # The result for each antibiotic is stored as an interim field breakpoints = breakpoints_analysis.getInterimFields() values = target_analysis.getInterimFields() - categories = sensitivity.getInterimFields() + antibiotics = sensitivity.getInterimFields() # Get the mapping of Antibiotic -> BreakpointsTable breakpoints = dict(map(lambda b: (b['uid'], b['value']), breakpoints)) @@ -108,12 +108,16 @@ def calc_sensitivity_categories(analysis): # Get the microorganism this analysis is associated to microorganism = get_microorganism(analysis) - # Update sensitivity categories - for category in categories: + # Update sensitivity category for each antibiotic + for antibiotic in antibiotics: + # Skip non-editable antibiotics + if not utils.is_interim_editable(antibiotic): + continue + # If extrapolated, assume same zone size as representative - abx_uid = category.get("primary") + abx_uid = antibiotic.get("primary") if not api.is_uid(abx_uid): - abx_uid = category["uid"] + abx_uid = antibiotic["uid"] # Get the zone size / MIC value value = values.get(abx_uid) @@ -121,7 +125,7 @@ def calc_sensitivity_categories(analysis): # No value entered yet or not floatable continue - # Get the selected Breakpoints Table for this category + # Get the selected Breakpoints Table for this antibiotic breakpoints_uid = breakpoints.get(abx_uid) # Get the breakpoint for this microorganism and antibiotic @@ -133,10 +137,10 @@ def calc_sensitivity_categories(analysis): cat = get_sensitivity_category_value(key, default="") # Update the sensitivity category - category.update({"value": cat}) + antibiotic.update({"value": cat}) - # Assign the updated categories to the sensitivity category analysis - sensitivity.setInterimFields(categories) + # Assign the antibiotics with the updated sensitivity categories + sensitivity.setInterimFields(antibiotics) def calc_disk_dosages(analysis): @@ -171,12 +175,16 @@ def calc_disk_dosages(analysis): breakpoints = dict(map(lambda b: (b['uid'], b['value']), breakpoints)) # Dosages are stored as interim fields - disk_dosages = disk_dosages_analysis.getInterimFields() - for dosage in disk_dosages: + antibiotics = disk_dosages_analysis.getInterimFields() + for antibiotic in antibiotics: + # Skip non-editable antibiotics + if not utils.is_interim_editable(antibiotic): + continue + # If extrapolated, assume same zone size as representative - abx_uid = dosage.get("primary") + abx_uid = antibiotic.get("primary") if not api.is_uid(abx_uid): - abx_uid = dosage["uid"] + abx_uid = antibiotic["uid"] # Get the selected Breakpoints Table for this category breakpoints_uid = breakpoints.get(abx_uid) @@ -189,10 +197,10 @@ def calc_disk_dosages(analysis): # Update the dosage breakpoint_dosage = breakpoint.get("disk_content") if api.to_float(breakpoint_dosage, default=0) > 0: - dosage.update({"value": breakpoint_dosage}) + antibiotic.update({"value": breakpoint_dosage}) # Assign the inferred disk dosages - disk_dosages_analysis.setInterimFields(disk_dosages) + disk_dosages_analysis.setInterimFields(antibiotics) def update_extrapolated_antibiotics(analysis): diff --git a/src/senaite/ast/config.py b/src/senaite/ast/config.py index 10143ab..9c9fa6a 100644 --- a/src/senaite/ast/config.py +++ b/src/senaite/ast/config.py @@ -79,6 +79,9 @@ # Description for autogenerated contents AUTOGENERATED = _(u"Autogenerated by senaite.ast") +# Abbreviation for "Not tested" +NOT_TESTED = "NT" + # Id of the Diffusion Disk method METHOD_DIFFUSION_DISK_ID = "diffusion_disk" diff --git a/src/senaite/ast/configure.zcml b/src/senaite/ast/configure.zcml index 39e4414..9c1ca38 100644 --- a/src/senaite/ast/configure.zcml +++ b/src/senaite/ast/configure.zcml @@ -13,11 +13,15 @@ + + + + diff --git a/src/senaite/ast/datamanagers.py b/src/senaite/ast/datamanagers.py index 3bdee16..3571c64 100644 --- a/src/senaite/ast/datamanagers.py +++ b/src/senaite/ast/datamanagers.py @@ -18,12 +18,15 @@ # Copyright 2020-2024 by it's authors. # Some rights reserved, see README and LICENSE. +from senaite.ast import logger from senaite.ast.config import BREAKPOINTS_TABLE_KEY from senaite.ast.config import DISK_CONTENT_KEY from senaite.ast.config import RESISTANCE_KEY from senaite.ast.config import ZONE_SIZE_KEY from senaite.ast.interfaces import IASTAnalysis from senaite.ast.utils import get_ast_siblings +from senaite.ast.utils import is_ast_analysis +from senaite.ast.utils import is_interim_editable from senaite.core.datamanagers.analysis import RoutineAnalysisDataManager from zope.component import adapter @@ -33,6 +36,32 @@ class ASTAnalysisDataManager(RoutineAnalysisDataManager): """Data Manager for AST-like analyses """ + def get_antibiotic_interim(self, keyword, default=None): + """Returns the interim that represents the antibiotic with the given + keyword, but only if the context is an AST-like analysis + """ + if not is_ast_analysis(self.context): + return default + + for interim in self.context.getInterimFields(): + if interim.get("keyword") == keyword: + return interim + + return default + + def set(self, name, value): + """Set analysis field/interim value + """ + # Check if an antibiotic/interim of an AST analysis + antibiotic = self.get_antibiotic_interim(name) + if antibiotic and not is_interim_editable(antibiotic): + logger.error("Interim field '{}' not writeable!".format(name)) + return [] + + # rely on the base class + base = super(ASTAnalysisDataManager, self) + return base.set(name, value) + def recalculate_results(self, obj, recalculated=None): recalculated = super(ASTAnalysisDataManager, self).\ recalculate_results(obj, recalculated=recalculated) diff --git a/src/senaite/ast/i18n.py b/src/senaite/ast/i18n.py new file mode 100644 index 0000000..a035e56 --- /dev/null +++ b/src/senaite/ast/i18n.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.AST. +# +# SENAITE.AST 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 2020-2024 by it's authors. +# Some rights reserved, see README and LICENSE. + +from senaite.core.i18n import translate as core_translate + + +def translate(msgid, **kwargs): + """Translate any zope i18n msgid returned from MessageFactory + """ + domain = kwargs.pop("domain", "senaite.ast") + return core_translate(msgid, domain=domain, **kwargs) diff --git a/src/senaite/ast/permissions.py b/src/senaite/ast/permissions.py new file mode 100644 index 0000000..292858c --- /dev/null +++ b/src/senaite/ast/permissions.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.AST. +# +# SENAITE.AST 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 2020-2024 by it's authors. +# Some rights reserved, see README and LICENSE. + +TransitionRejectAntibiotics = "senaite.ast: Transition: Reject Antibiotics" diff --git a/src/senaite/ast/permissions.zcml b/src/senaite/ast/permissions.zcml new file mode 100644 index 0000000..a3dd245 --- /dev/null +++ b/src/senaite/ast/permissions.zcml @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/senaite/ast/profiles/default/metadata.xml b/src/senaite/ast/profiles/default/metadata.xml index f785b9b..2bc3e64 100644 --- a/src/senaite/ast/profiles/default/metadata.xml +++ b/src/senaite/ast/profiles/default/metadata.xml @@ -6,7 +6,7 @@ dependencies before installing this add-on own profile. --> - 1200 + 1202 diff --git a/src/senaite/ast/profiles/default/rolemap.xml b/src/senaite/ast/profiles/default/rolemap.xml new file mode 100644 index 0000000..ccfb4a9 --- /dev/null +++ b/src/senaite/ast/profiles/default/rolemap.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/senaite/ast/setuphandlers.py b/src/senaite/ast/setuphandlers.py index 1aebce9..ab4f224 100644 --- a/src/senaite/ast/setuphandlers.py +++ b/src/senaite/ast/setuphandlers.py @@ -29,9 +29,12 @@ from senaite.ast.config import AST_CALCULATION_TITLE from senaite.ast.config import AST_POINT_OF_CAPTURE from senaite.ast.config import AUTOGENERATED -from senaite.ast.config import IDENTIFICATION_KEY from senaite.ast.config import SERVICE_CATEGORY from senaite.ast.config import SERVICES_SETTINGS +from senaite.ast.permissions import TransitionRejectAntibiotics +from senaite.core.api.workflow import update_workflow +from senaite.core.catalog import SETUP_CATALOG +from senaite.core.workflow import ANALYSIS_WORKFLOW from zope.component import getUtility # Tuples of (folder_id, folder_name, type) @@ -47,6 +50,33 @@ ]) ] +WORKFLOWS_TO_UPDATE = { + ANALYSIS_WORKFLOW: { + "states": { + "assigned": { + "transitions": ["modal_reject_antibiotics"], + }, + "unassigned": { + "transitions": ["modal_reject_antibiotics"], + }, + }, + "transitions": { + "modal_reject_antibiotics": { + "title": "Reject antibiotics", + "new_state": "", + "action": "Reject antibiotics", + "action_url": "%(content_url)s/reject_antibiotics", + "guard": { + "guard_permissions": TransitionRejectAntibiotics, + "guard_roles": "", + "guard_expr": + "python:here.guard_handler('reject_antibiotics')", + } + } + } + } +} + def setup_handler(context): """Generic setup handler @@ -71,6 +101,12 @@ def setup_handler(context): # Add behaviors setup_behaviors(portal) + # setup workflows + setup_workflows(portal) + + # Revoke edit permissions for ast setup objects + revoke_edition_permissions(portal) + logger.info("{} setup handler [DONE]".format(PRODUCT_NAME.upper())) @@ -117,9 +153,10 @@ def setup_ast_category(portal): """ name = SERVICE_CATEGORY logger.info("Setup category '{}' ...".format(name)) - folder = api.get_setup().bika_analysiscategories - exists = filter(lambda c: api.get_title(c) == name, folder.objectValues()) - if exists: + setup = api.get_senaite_setup() + folder = setup.analysiscategories + categories = search_by_title(folder, name) + if categories: logger.info("Category '{}' exists already [SKIP]".format(name)) return @@ -163,15 +200,15 @@ def setup_ast_services(portal, update_existing=True): selective reporting """ logger.info("Setup AST services ...") + + # Get the category to apply to all AST services + cats_folder = api.get_senaite_setup().analysiscategories + category = search_by_title(cats_folder, SERVICE_CATEGORY)[0] + setup = api.get_setup() for key, settings in SERVICES_SETTINGS.items(): logger.info("Setup template service '{}' ...".format(key)) - # Get the category - cat_name = SERVICE_CATEGORY - categories = setup.bika_analysiscategories.objectValues() - category = filter(lambda c: api.get_title(c) == cat_name, categories)[0] - title = settings["title"] if "{}" in title: title = title.format(_("Antibiotic Sensitivity")) @@ -247,6 +284,33 @@ def remove_behaviors(portal): logger.info("Removing Behaviors [DONE]") +def revoke_edition_permissions(portal): + """Revoke the 'Modify portal content' permission to 'ast' services + """ + logger.info("Revoking edition permissions to AST setup objects ...") + + def revoke_permission(obj): + obj = api.get_object(obj) + roles = security.get_valid_roles_for(obj) + security.revoke_permission_for(obj, ModifyPortalContent, roles) + obj.reindexObject() + + # analysis services + query = { + "portal_type": "AnalysisService", + "point_of_capture": AST_POINT_OF_CAPTURE + } + brains = api.search(query, SETUP_CATALOG) + map(revoke_permission, brains) + + # calculation + query = {"portal_type": "Calculation", "title": AST_CALCULATION_TITLE} + brains = api.search(query, SETUP_CATALOG) + map(revoke_permission, brains) + + logger.info("Revoking edition permissions to AST setup objects [DONE]") + + def search_by_title(container, title): """Returns the items from the container that match with the title passed-in """ @@ -254,6 +318,15 @@ def search_by_title(container, title): return filter(lambda obj: api.get_title(obj) == title, objs) +def setup_workflows(portal): + """Setup workflow changes (status, transitions, permissions, etc.) + """ + logger.info("Setup workflows ...") + for wf_id, settings in WORKFLOWS_TO_UPDATE.items(): + update_workflow(wf_id, **settings) + logger.info("Setup workflows [DONE]") + + def pre_install(portal_setup): """Runs before the first import step of the *default* profile This handler is registered as a *pre_handler* in the generic setup profile diff --git a/src/senaite/ast/subscribers/__init__.py b/src/senaite/ast/subscribers/__init__.py new file mode 100644 index 0000000..6899a65 --- /dev/null +++ b/src/senaite/ast/subscribers/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.AST. +# +# SENAITE.AST 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 2020-2024 by it's authors. +# Some rights reserved, see README and LICENSE. diff --git a/src/senaite/ast/subscribers/configure.zcml b/src/senaite/ast/subscribers/configure.zcml new file mode 100644 index 0000000..6925721 --- /dev/null +++ b/src/senaite/ast/subscribers/configure.zcml @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/senaite/ast/subscribers/upgrade.py b/src/senaite/ast/subscribers/upgrade.py new file mode 100644 index 0000000..3ad8b4e --- /dev/null +++ b/src/senaite/ast/subscribers/upgrade.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# +# This file is part of SENAITE.AST. +# +# SENAITE.AST 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 2020-2024 by it's authors. +# Some rights reserved, see README and LICENSE. + + +from bika.lims.api import get_portal +from senaite.ast import is_installed +from senaite.ast import logger +from senaite.ast import PRODUCT_NAME +from senaite.ast.setuphandlers import revoke_edition_permissions +from senaite.ast.setuphandlers import setup_behaviors +from senaite.ast.setuphandlers import setup_navigation_types +from senaite.ast.setuphandlers import setup_workflows + + +def afterUpgradeStepHandler(event): + """Event handler executed after running an upgrade step of senaite.core + """ + if not is_installed(): + return + + logger.info("Run {}.afterUpgradeStepHandler ...".format(PRODUCT_NAME)) + portal = get_portal() + setup = portal.portal_setup # noqa + + profile = "profile-{0}:default".format(PRODUCT_NAME) + setup.runImportStepFromProfile(profile, "typeinfo") + setup.runImportStepFromProfile(profile, "rolemap") + setup.runImportStepFromProfile(profile, "workflow") + + # Configure visible navigation items + setup_navigation_types(portal) + + # Setup additional behaviors + setup_behaviors(portal) + + # Setup workflows + setup_workflows(portal) + + # Revoke edit permissions for ast setup objects + revoke_edition_permissions(portal) + + logger.info("Run {}.afterUpgradeStepHandler [DONE]".format(PRODUCT_NAME)) diff --git a/src/senaite/ast/tests/base.py b/src/senaite/ast/tests/base.py index ad490c7..07ea9e4 100644 --- a/src/senaite/ast/tests/base.py +++ b/src/senaite/ast/tests/base.py @@ -18,31 +18,45 @@ # Copyright 2020-2024 by it's authors. # Some rights reserved, see README and LICENSE. -from plone.app.testing import TEST_USER_ID -from plone.app.testing import setRoles -from plone.app.testing.bbb_at import PloneTestCase -from plone.protect.authenticator import createToken -from senaite.ast.tests.layers import BASE_TESTING +import transaction +from plone.app.testing import applyProfile +from plone.app.testing import FunctionalTesting +from plone.testing import zope +from senaite.core.tests.base import BaseTestCase +from senaite.core.tests.layers import BaseLayer -class BaseTestCase(PloneTestCase): - """Use for test cases which do not rely on the demo data +class SimpleTestLayer(BaseLayer): + + def setUpZope(self, app, configurationContext): + super(SimpleTestLayer, self).setUpZope(app, configurationContext) + + # Load ZCML + import senaite.abx + import senaite.microorganism + self.loadZCML(package=senaite.abx) + self.loadZCML(package=senaite.microorganism) + self.loadZCML(package=senaite.ast) + + # Install product and call its initialize() function + zope.installProduct(app, "senaite.abx") + zope.installProduct(app, "senaite.microorganism") + zope.installProduct(app, "senaite.ast") + + def setUpPloneSite(self, portal): + super(SimpleTestLayer, self).setUpPloneSite(portal) + applyProfile(portal, "senaite.ast:default") + transaction.commit() + + +SIMPLE_TEST_LAYER_FIXTURE = SimpleTestLayer() +SIMPLE_TESTING = FunctionalTesting( + bases=(SIMPLE_TEST_LAYER_FIXTURE, ), + name="senaite.ast:SimpleTesting" +) + + +class SimpleTestCase(BaseTestCase): + """Use for test cases which do not rely on demo data """ - layer = BASE_TESTING - - def setUp(self): - super(BaseTestCase, self).setUp() - # Fixing CSRF protection - # https://github.com/plone/plone.protect/#fixing-csrf-protection-failures-in-tests - self.request = self.layer["request"] - # Disable plone.protect for these tests - self.request.form["_authenticator"] = createToken() - # Eventuelly you find this also useful - self.request.environ["REQUEST_METHOD"] = "POST" - - setRoles(self.portal, TEST_USER_ID, ["LabManager", "Manager"]) - - # Default skin is set to "Sunburst Theme"! - # => This causes an `AttributeError` when we want to access - # e.g. 'guard_handler' FSPythonScript - self.portal.changeSkin("Plone Default") + layer = SIMPLE_TESTING diff --git a/src/senaite/ast/tests/doctests/AST.rst b/src/senaite/ast/tests/doctests/AST.rst index 33e69e3..e4206c9 100644 --- a/src/senaite/ast/tests/doctests/AST.rst +++ b/src/senaite/ast/tests/doctests/AST.rst @@ -44,8 +44,8 @@ We need to create some basic objects for the test: >>> contact = api.create(client, "Contact", Firstname="Rita", Lastname="Mohale") >>> sampletype = api.create(setup.bika_sampletypes, "SampleType", title="Blood", Prefix="B") >>> labcontact = api.create(setup.bika_labcontacts, "LabContact", Firstname="Lab", Lastname="Manager") - >>> department = api.create(setup.bika_departments, "Department", title="Microbiology", Manager=labcontact) - >>> category = api.create(setup.bika_analysiscategories, "AnalysisCategory", title="Microbiology", Department=department) + >>> department = api.create(portal.setup.departments, "Department", title="Microbiology", Manager=labcontact) + >>> category = api.create(portal.setup.analysiscategories, "AnalysisCategory", title="Microbiology", Department=department) >>> g = api.create(setup.bika_analysisservices, "AnalysisService", title="GRAM Test", Keyword="G", Price="15", Category=category.UID(), Accredited=True) diff --git a/src/senaite/ast/tests/layers.py b/src/senaite/ast/tests/layers.py deleted file mode 100644 index dafc302..0000000 --- a/src/senaite/ast/tests/layers.py +++ /dev/null @@ -1,75 +0,0 @@ -# -*- coding: utf-8 -*- -# -# This file is part of SENAITE.AST. -# -# SENAITE.AST 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 2020-2024 by it's authors. -# Some rights reserved, see README and LICENSE. - -import transaction -from plone.app.testing import applyProfile -from plone.app.testing import FunctionalTesting -from plone.app.testing import PLONE_FIXTURE -from plone.app.testing import PloneSandboxLayer -from plone.testing import zope - - -class BaseLayer(PloneSandboxLayer): - defaultBases = (PLONE_FIXTURE,) - - def setUpZope(self, app, configurationContext): - super(BaseLayer, self).setUpZope(app, configurationContext) - - # Load ZCML - import bika.lims - import senaite.app.listing - import senaite.app.spotlight - import senaite.core - import senaite.abx - import senaite.ast - import senaite.impress - import senaite.microorganism - - self.loadZCML(package=bika.lims) - self.loadZCML(package=senaite.core) - self.loadZCML(package=senaite.app.listing) - self.loadZCML(package=senaite.app.spotlight) - self.loadZCML(package=senaite.impress) - self.loadZCML(package=senaite.lims) - self.loadZCML(package=senaite.abx) - self.loadZCML(package=senaite.microorganism) - self.loadZCML(package=senaite.ast) - - # Install product and call its initialize() function - zope.installProduct(app, "bika.lims") - zope.installProduct(app, "senaite.core") - zope.installProduct(app, "senaite.app.listing") - zope.installProduct(app, "senaite.app.spotlight") - zope.installProduct(app, "senaite.impress") - zope.installProduct(app, "senaite.lims") - zope.installProduct(app, "senaite.abx") - zope.installProduct(app, "senaite.microorganism") - zope.installProduct(app, "senaite.ast") - - def setUpPloneSite(self, portal): - # Install into Plone site using portal_setup - applyProfile(portal, "senaite.core:default") - applyProfile(portal, "senaite.ast:default") - transaction.commit() - - -BASE_LAYER_FIXTURE = BaseLayer() -BASE_TESTING = FunctionalTesting( - bases=(BASE_LAYER_FIXTURE,), name="SENAITE.AST:BaseTesting") diff --git a/src/senaite/ast/tests/test_doctests.py b/src/senaite/ast/tests/test_doctests.py index 2cb7b28..a472506 100644 --- a/src/senaite/ast/tests/test_doctests.py +++ b/src/senaite/ast/tests/test_doctests.py @@ -25,7 +25,7 @@ import unittest2 as unittest from senaite.ast import PRODUCT_NAME -from senaite.ast.tests.base import BaseTestCase +from senaite.ast.tests.base import SimpleTestCase from Testing import ZopeTestCase as ztc # Option flags for doctests @@ -38,7 +38,7 @@ def test_suite(): suite.addTests([ ztc.ZopeDocFileSuite( doctestfile, - test_class=BaseTestCase, + test_class=SimpleTestCase, optionflags=flags ) ]) diff --git a/src/senaite/ast/upgrade/v01_00_000.py b/src/senaite/ast/upgrade/v01_00_000.py index 76ef321..7550581 100644 --- a/src/senaite/ast/upgrade/v01_00_000.py +++ b/src/senaite/ast/upgrade/v01_00_000.py @@ -33,6 +33,7 @@ from senaite.ast.setuphandlers import setup_navigation_types from senaite.ast.utils import get_result_options from senaite.core.catalog import SETUP_CATALOG +from senaite.core.interfaces import ISampleTemplate from senaite.core.upgrade import upgradestep from senaite.core.upgrade.utils import UpgradeUtils @@ -146,15 +147,11 @@ def remove_ast_from_profiles(portal): """ logger.info("Removing AST-like analyses from profiles ...") ast_uids = get_ast_services_uids(portal) - query = { - "portal_type": "AnalysisProfile" - } - brains = api.search(query, SETUP_CATALOG) - for brain in brains: - obj = api.get_object(brain) - services = obj.getRawService() or [] - services = filter(lambda s: s not in ast_uids, services) - obj.setService(services) + profiles = portal.setup.analysisprofiles.objectValues() + for obj in profiles: + services = obj.getRawServices() or [] + services = filter(lambda s: s.get("uid") not in ast_uids, services) + obj.setServices(services) logger.info("Removing AST-like analyses from profiles [DONE]") @@ -164,11 +161,18 @@ def remove_ast_from_templates(portal): logger.info("Removing AST-like analyses from templates ...") ast_uids = get_ast_services_uids(portal) query = { - "portal_type": "ARTemplate" + "portal_type": ["ARTemplate", "SampleTemplate"] } brains = api.search(query, SETUP_CATALOG) for brain in brains: obj = api.get_object(brain) + if ISampleTemplate.providedBy(obj): + ans = obj.getRawServices() + ans = filter(lambda an: an.get("uid") not in ast_uids, ans) + obj.setServices(list(ans)) + continue + + # Old AT object (https://github.com/senaite/senaite.core/pull/2521) ans = obj.getAnalyses() ans = filter(lambda an: an.get("service_uid") not in ast_uids, ans) obj.setAnalyses(ans) diff --git a/src/senaite/ast/upgrade/v01_02_000.py b/src/senaite/ast/upgrade/v01_02_000.py index 8c0ba24..547470f 100644 --- a/src/senaite/ast/upgrade/v01_02_000.py +++ b/src/senaite/ast/upgrade/v01_02_000.py @@ -18,12 +18,18 @@ # Copyright 2020-2024 by it's authors. # Some rights reserved, see README and LICENSE. +from bika.lims import api from senaite.ast import logger from senaite.ast import PRODUCT_NAME +from senaite.ast.config import AST_POINT_OF_CAPTURE +from senaite.ast.setuphandlers import revoke_edition_permissions +from senaite.ast.setuphandlers import setup_workflows +from senaite.core.catalog import ANALYSIS_CATALOG from senaite.core.upgrade import upgradestep from senaite.core.upgrade.utils import UpgradeUtils version = "1.1.0" +profile = "profile-{0}:default".format(PRODUCT_NAME) @upgradestep(PRODUCT_NAME, version) @@ -45,3 +51,43 @@ def upgrade(tool): logger.info("{0} upgraded to version {1}".format(PRODUCT_NAME, version)) return True + + +def setup_reject_antibiotics(tool): + logger.info("Setup reject antibiotics transition ...") + portal = tool.aq_inner.aq_parent + + # import rolemap and workflow + setup = portal.portal_setup + setup.runImportStepFromProfile(profile, "rolemap") + setup.runImportStepFromProfile(profile, "workflow") + + # setup custom workflow modifs + setup_workflows(portal) + + # update role mappings + statuses = ["assigned", "unassigned"] + cat = api.get_tool(ANALYSIS_CATALOG) + brains = cat(portal_type="Analysis", review_state=statuses, + getPointOfCapture=AST_POINT_OF_CAPTURE) + map(update_role_mappings_for, brains) + + logger.info("Setup reject antibiotics transition [DONE]") + + +def update_role_mappings_for(object_or_brain): + """Update role mappings for the specified object + """ + obj = api.get_object(object_or_brain) + path = api.get_path(obj) + logger.info("Updating workflow role mappings for {} ...".format(path)) + tool = api.get_tool("portal_workflow") + for wf_id in api.get_workflows_for(obj): + wf = tool.getWorkflowById(wf_id) + wf.updateRoleMappingsFor(obj) + obj.reindexObject(idxs=["allowedRolesAndUsers"]) + + +def revoke_setup_permissions(tool): + portal = tool.aq_inner.aq_parent + revoke_edition_permissions(portal) diff --git a/src/senaite/ast/upgrade/v01_02_000.zcml b/src/senaite/ast/upgrade/v01_02_000.zcml index ef1cb7d..b63486f 100644 --- a/src/senaite/ast/upgrade/v01_02_000.zcml +++ b/src/senaite/ast/upgrade/v01_02_000.zcml @@ -1,7 +1,22 @@ + xmlns:genericsetup="http://namespaces.zope.org/genericsetup"> + + + +