From 6a180745619b3665a6ada26653b6d86ab477419e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Sat, 3 Jun 2023 20:21:00 +0200 Subject: [PATCH 01/14] Optimize the retrieval of method uids from abstractanalysis --- bika/lims/content/abstractanalysis.py | 20 ++++++++++++++++---- bika/lims/content/analysisservice.py | 1 - 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/bika/lims/content/abstractanalysis.py b/bika/lims/content/abstractanalysis.py index 90b3de914e..1a204c872c 100644 --- a/bika/lims/content/abstractanalysis.py +++ b/bika/lims/content/abstractanalysis.py @@ -707,7 +707,7 @@ def isMethodAllowed(self, method): :rtype: bool """ uid = api.get_uid(method) - return uid in self.getAllowedMethodUIDs() + return uid in self.getRawAllowedMethods() @security.public def getAllowedMethods(self): @@ -718,17 +718,29 @@ def getAllowedMethods(self): :return: A list with the methods allowed for this analysis :rtype: list of Methods """ + uids = self.getRawAllowedMethods() + brains = api.search({"UID": uids}, catalog="uid_catalog") + methods = [api.get_object(brain, default=None) for brain in brains] + return filter(None, methods) + + @security.public + def getRawAllowedMethods(self): + """Returns the UIDs of the allowed methods for this analysis + """ service = self.getAnalysisService() if not service: return [] methods = [] if self.getManualEntryOfResults(): - methods = service.getMethods() + methods = service.getRawMethods()[:] + if self.getInstrumentEntryOfResults(): for instrument in service.getInstruments(): - methods.extend(instrument.getMethods()) + instrument_methods = instrument.getRawMethods()[:] + methods.extend(instrument_methods) + methods = filter(api.is_uid, methods) return list(set(methods)) @security.public @@ -738,7 +750,7 @@ def getAllowedMethodUIDs(self): :return: A list with the UIDs of the methods allowed for this analysis :rtype: list of strings """ - return [m.UID() for m in self.getAllowedMethods()] + return self.getRawAllowedMethods() @security.public def getAllowedInstruments(self): diff --git a/bika/lims/content/analysisservice.py b/bika/lims/content/analysisservice.py index ff5c18a5a3..1a90f22607 100644 --- a/bika/lims/content/analysisservice.py +++ b/bika/lims/content/analysisservice.py @@ -496,7 +496,6 @@ def getAvailableMethodUIDs(self): Returns the UIDs of the available methods. it is used as a vocabulary to fill the selection list of 'Methods' field. """ - # N.B. we return a copy of the list to avoid accidental writes method_uids = self.getRawMethods()[:] if self.getInstrumentEntryOfResults(): for instrument in self.getInstruments(): From 991b73552606e8148c1c3115bd8097763d537500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Sat, 3 Jun 2023 20:46:57 +0200 Subject: [PATCH 02/14] Improve the createion process of AT content types --- bika/lims/api/__init__.py | 45 ++++++++++++++++-- bika/lims/subscribers/batch.py | 35 ++++++++++---- bika/lims/subscribers/configure.zcml | 6 ++- bika/lims/tests/doctests/API.rst | 70 ++++++++++++++++++++++++++-- 4 files changed, 140 insertions(+), 16 deletions(-) diff --git a/bika/lims/api/__init__.py b/bika/lims/api/__init__.py index d497c9c159..e8ae79cdfe 100644 --- a/bika/lims/api/__init__.py +++ b/bika/lims/api/__init__.py @@ -44,8 +44,11 @@ from plone.memoize.volatile import DontCache from Products.Archetypes.atapi import DisplayList from Products.Archetypes.BaseObject import BaseObject +from Products.Archetypes.event import ObjectInitializedEvent +from Products.Archetypes.utils import mapply from Products.CMFCore.interfaces import IFolderish from Products.CMFCore.interfaces import ISiteRoot +from Products.CMFCore.permissions import ModifyPortalContent from Products.CMFCore.utils import getToolByName from Products.CMFCore.WorkflowCore import WorkflowException from Products.CMFPlone.RegistrationTool import get_member_by_login_name @@ -141,10 +144,17 @@ def create(container, portal_type, *args, **kwargs): fti = types_tool.getTypeInfo(portal_type) if fti.product: + # create the AT object obj = _createObjectByType(portal_type, container, id) - obj.processForm() - obj.edit(title=title, **kwargs) - modified(obj) + # update the object with values + edit(obj, check_permissions=False, title=title, **kwargs) + # auto-id if required + if obj._at_rename_after_creation: + obj._renameAfterCreation(check_auto_id=True) + # we are no longer under creation + obj.unmarkCreationFlag() + # notify that the object was created + notify(ObjectInitializedEvent(obj)) else: # newstyle factory factory = getUtility(IFactory, fti.factory) @@ -165,6 +175,35 @@ def create(container, portal_type, *args, **kwargs): return obj +def edit(obj, check_permissions=True, **kwargs): + """Updates the values of object fields with the new values passed-in + """ + # Prevent circular dependencies + from security import check_permission + fields = get_fields(obj) + for name, value in kwargs.items(): + field = fields.get(name, None) + if not field: + continue + + # cannot update readonly fields + readonly = getattr(field, "readonly", False) + if readonly: + raise ValueError("Field '{}' is readonly".format(name)) + + # check field writable permission + if check_permissions: + perm = getattr(field, "write_permission", ModifyPortalContent) + if perm and not check_permission(perm, obj): + raise Unauthorized("Field '{}' is not writeable".format(name)) + + # Set the value + if hasattr(field, "getMutator"): + mutator = field.getMutator(obj) + mapply(mutator, value) + else: + field.set(obj, value) + def get_tool(name, context=None, default=_marker): """Get a portal tool by name diff --git a/bika/lims/subscribers/batch.py b/bika/lims/subscribers/batch.py index e07b4a1d14..9621770c68 100644 --- a/bika/lims/subscribers/batch.py +++ b/bika/lims/subscribers/batch.py @@ -21,14 +21,10 @@ from Products.CMFPlone.utils import safe_unicode -def ObjectModifiedEventHandler(batch, event): - """Actions to be done when a batch is created: - - Title as the Batch ID if title is not defined - - Move the Batch inside the Client if defined +def move_to_client(batch): + """Moves the batch to the client assigned in the Client field if it does + not belong to that client yet. Does nothing otherwise """ - if not batch.title: - batch.setTitle(safe_unicode(batch.id).encode('utf-8')) - # If client is assigned, move the Batch the client's folder # Note here we directly get the Client from the Schema, cause getClient # getter is overriden in Batch content type to always look to aq_parent in @@ -39,5 +35,26 @@ def ObjectModifiedEventHandler(batch, event): # Check if the Batch is being created inside the Client if client and (client.UID() != batch.aq_parent.UID()): # move batch inside the client - cp = batch.aq_parent.manage_cutObjects(batch.id) - client.manage_pasteObjects(cp) + if batch.id in batch.aq_parent.objectIds(): + cp = batch.aq_parent.manage_cutObjects(batch.id) + client.manage_pasteObjects(cp) + + +def ObjectInitializedEventHandler(batch, event): + """Actions to be done when a batch is created: + - Title as the Batch ID if title is not defined + - Move the Batch inside the Client if defined + """ + if not batch.title: + batch.setTitle(safe_unicode(batch.id).encode('utf-8')) + + # Try to move the batch to it's client folder + move_to_client(batch) + + +def ObjectModifiedEventHandler(batch, event): + """Moves the batch object inside the folder that represents the client this + batch has been assigned to via "Client" field + """ + # Try to move the batch to it's client folder + move_to_client(batch) diff --git a/bika/lims/subscribers/configure.zcml b/bika/lims/subscribers/configure.zcml index 292673f9e0..ab18b5a8f5 100644 --- a/bika/lims/subscribers/configure.zcml +++ b/bika/lims/subscribers/configure.zcml @@ -110,7 +110,7 @@ handler="bika.lims.subscribers.analysisrequest.AfterTransitionEventHandler" /> - + >> client - >>> client.Title() - 'Test Client' + >>> client.Title() + 'Test Client' + +Created objects are properly indexed:: + + >>> services = self.portal.bika_setup.bika_analysisservices + >>> service = api.create(services, "AnalysisService", + ... title="Dummy service", Keyword="DUM") + >>> uid = api.get_uid(service) + >>> catalog = api.get_tool("bika_setup_catalog") + >>> brains = catalog(portal_type="AnalysisService", UID=uid) + >>> brains[0].getKeyword + 'DUM' + + +Editing Content +............... + +This function helps to edit a given content. + +Here we update the `Client` we created earlier, an AT:: + + >>> api.edit(client, AccountNumber="12343567890", BankName="BTC Bank") + >>> client.getAccountNumber() + '12343567890' + + >>> client.getBankName() + 'BTC Bank' + +The field need to be writeable:: + + >>> field = client.getField("BankName") + >>> field.readonly = True + >>> api.edit(client, BankName="Lydian Lion Coins Bank") + Traceback (most recent call last): + [...] + ValueError: Field 'BankName' is readonly + + >>> client.getBankName() + 'BTC Bank' + + >>> field.readonly = False + >>> api.edit(client, BankName="Lydian Lion Coins Bank") + >>> client.getBankName() + 'Lydian Lion Coins Bank' + +And user need to have enough permissions to change the value as well:: + + >>> field.write_permission = "Delete objects" + >>> api.edit(client, BankName="Electrum Coins") + Traceback (most recent call last): + [...] + Unauthorized: Field 'BankName' is not writeable + + >>> client.getBankName() + 'Lydian Lion Coins Bank' + +Unless we manually force to bypass the permissions check:: + + >>> api.edit(client, check_permissions=False, BankName="Electrum Coins") + >>> client.getBankName() + 'Electrum Coins' + +Restore permission:: + + >>> field.write_permission = "Modify Portal Content" Getting a Tool @@ -338,7 +402,7 @@ The portal object actually has no UID. This funciton defines it therefore to be >>> uid_client = api.get_uid(client) >>> uid_client_brain = api.get_uid(brain) - >>> uid_client is uid_client_brain + >>> uid_client == uid_client_brain True If a UID is passed to the function, it will return the value unchanged: From 20300a739a71cc31f504faa06f4e8811dc7e4de3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Sat, 3 Jun 2023 21:09:19 +0200 Subject: [PATCH 03/14] Cleanup get*UID functions to prevent unnecessary searches and instantiations --- bika/lims/content/abstractanalysis.py | 37 ++++++++++++-------- bika/lims/content/abstractbaseanalysis.py | 4 +-- bika/lims/content/abstractroutineanalysis.py | 7 ++-- bika/lims/content/analysis.py | 2 +- bika/lims/content/duplicateanalysis.py | 2 +- bika/lims/content/worksheet.py | 5 +-- bika/lims/content/worksheettemplate.py | 5 +-- 7 files changed, 32 insertions(+), 30 deletions(-) diff --git a/bika/lims/content/abstractanalysis.py b/bika/lims/content/abstractanalysis.py index 1a204c872c..61cd15b544 100644 --- a/bika/lims/content/abstractanalysis.py +++ b/bika/lims/content/abstractanalysis.py @@ -1166,26 +1166,35 @@ def getInterimValue(self, keyword): def isRetest(self): """Returns whether this analysis is a retest or not """ - return self.getRetestOf() and True or False + if self.getRawRetestOf(): + return True + return False def getRetestOfUID(self): """Returns the UID of the retracted analysis this is a retest of """ - retest_of = self.getRetestOf() - if retest_of: - return api.get_uid(retest_of) + return self.getRawRetestOf() - def getRetest(self): - """Returns the retest that comes from this analysis, if any + def getRawRetest(self): + """Returns the UID of the retest that comes from this analysis, if any """ relationship = "{}RetestOf".format(self.portal_type) - back_refs = get_backreferences(self, relationship) - if not back_refs: + uids = get_backreferences(self, relationship) + if not uids: return None - if len(back_refs) > 1: + if len(uids) > 1: logger.warn("Analysis {} with multiple retests".format(self.id)) - retest_uid = back_refs[0] - retest = api.get_object_by_uid(retest_uid, default=None) - if retest is None: - logger.error("Retest with UID {} not found".format(retest_uid)) - return retest + return uids[0] + + def getRetest(self): + """Returns the retest that comes from this analysis, if any + """ + retest_uid = self.getRawRetest() + return api.get_object(retest_uid, default=None) + + def isRetested(self): + """Returns whether this analysis has been retested or not + """ + if self.getRawRetest(): + return True + return False diff --git a/bika/lims/content/abstractbaseanalysis.py b/bika/lims/content/abstractbaseanalysis.py index fc846efb9c..f0249da228 100644 --- a/bika/lims/content/abstractbaseanalysis.py +++ b/bika/lims/content/abstractbaseanalysis.py @@ -1016,9 +1016,7 @@ def getCategoryTitle(self): def getCategoryUID(self): """Used to populate catalog values """ - category = self.getCategory() - if category: - return category.UID() + return self.getRawCategory() @security.public def getMaxTimeAllowed(self): diff --git a/bika/lims/content/abstractroutineanalysis.py b/bika/lims/content/abstractroutineanalysis.py index 5638c41c3e..00ab4b4ad9 100644 --- a/bika/lims/content/abstractroutineanalysis.py +++ b/bika/lims/content/abstractroutineanalysis.py @@ -315,9 +315,10 @@ def getSampleType(self): def getSampleTypeUID(self): """Used to populate catalog values. """ - sample_type = self.getSampleType() - if sample_type: - return api.get_uid(sample_type) + sample = self.getRequest() + if not sample: + return None + return sample.getRawSampleType() @security.public def getResultsRange(self): diff --git a/bika/lims/content/analysis.py b/bika/lims/content/analysis.py index 496e550c86..6694a3a800 100644 --- a/bika/lims/content/analysis.py +++ b/bika/lims/content/analysis.py @@ -64,7 +64,7 @@ def getSiblings(self, with_retests=False): if api.get_workflow_status_of(sibling) in retracted_states: # Exclude retracted analyses continue - elif sibling.getRetest(): + elif sibling.isRetested(): # Exclude analyses with a retest continue diff --git a/bika/lims/content/duplicateanalysis.py b/bika/lims/content/duplicateanalysis.py index d8c7f51247..9c2ae42d21 100644 --- a/bika/lims/content/duplicateanalysis.py +++ b/bika/lims/content/duplicateanalysis.py @@ -119,7 +119,7 @@ def getSiblings(self, with_retests=False): # Exclude retracted analyses continue - elif analysis.getRetest(): + elif analysis.isRetested(): # Exclude analyses with a retest continue diff --git a/bika/lims/content/worksheet.py b/bika/lims/content/worksheet.py index 04e7f1d7a2..7f9d7b20d0 100644 --- a/bika/lims/content/worksheet.py +++ b/bika/lims/content/worksheet.py @@ -1124,10 +1124,7 @@ def getWorksheetTemplateUID(self): :returns: worksheet's UID :rtype: UID as string """ - ws = self.getWorksheetTemplate() - if ws: - return ws.UID() - return '' + return self.getRawWorksheetTemplate() def getWorksheetTemplateTitle(self): """ diff --git a/bika/lims/content/worksheettemplate.py b/bika/lims/content/worksheettemplate.py index fb29184689..1112e21685 100644 --- a/bika/lims/content/worksheettemplate.py +++ b/bika/lims/content/worksheettemplate.py @@ -209,10 +209,7 @@ def getInstruments(self): def getMethodUID(self): """Return method UID """ - method = self.getRestrictToMethod() - if method: - return method.UID() - return "" + return self.getRawRestrictToMethod() def _getMethodsVoc(self): """Return the registered methods as DisplayList From 4299a8dceb6d8b33ba78c8840fde60a158e4c77e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Sat, 3 Jun 2023 21:12:27 +0200 Subject: [PATCH 04/14] Remove default_method from AnalysisRequest's Contact field --- bika/lims/content/analysisrequest.py | 15 --------------- bika/lims/content/client.py | 15 --------------- 2 files changed, 30 deletions(-) diff --git a/bika/lims/content/analysisrequest.py b/bika/lims/content/analysisrequest.py index 2b7149585f..1e26cd24b1 100644 --- a/bika/lims/content/analysisrequest.py +++ b/bika/lims/content/analysisrequest.py @@ -141,7 +141,6 @@ UIDReferenceField( 'Contact', required=1, - default_method='getContactUIDForUser', allowed_types=('Contact',), mode="rw", read_permission=View, @@ -1820,20 +1819,6 @@ def getVerifiers(self): contacts.append(contact) return contacts - security.declarePublic('getContactUIDForUser') - - def getContactUIDForUser(self): - """get the UID of the contact associated with the authenticated user - """ - mt = getToolByName(self, 'portal_membership') - user = mt.getAuthenticatedMember() - user_id = user.getUserName() - pc = getToolByName(self, 'portal_catalog') - r = pc(portal_type='Contact', - getUsername=user_id) - if len(r) == 1: - return r[0].UID - security.declarePublic('current_date') def current_date(self): diff --git a/bika/lims/content/client.py b/bika/lims/content/client.py index 315ec3a3c6..df7c457426 100644 --- a/bika/lims/content/client.py +++ b/bika/lims/content/client.py @@ -181,21 +181,6 @@ def getContactFromUsername(self, username): if contact.getUsername() == username: return contact.UID() - security.declarePublic("getContactUIDForUser") - - def getContactUIDForUser(self): - """Get the UID of the user associated with the authenticated user - """ - membership_tool = api.get_tool("portal_membership") - member = membership_tool.getAuthenticatedMember() - username = member.getUserName() - r = self.portal_catalog( - portal_type="Contact", - getUsername=username - ) - if len(r) == 1: - return r[0].UID - def getContacts(self, only_active=True): """Return an array containing the contacts from this Client """ From 780e3c93eb125938cea9b64912c5aa3d3a5c55ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Sun, 4 Jun 2023 14:18:12 +0200 Subject: [PATCH 05/14] Migrate AnalysisRequest's ReferenceField fields to UIDReferenceField --- bika/lims/content/analysisrequest.py | 121 +++++++++++++----------- bika/lims/content/attachment.py | 9 +- bika/lims/idserver.py | 3 +- bika/lims/profiles/default/metadata.xml | 2 +- bika/lims/upgrade/configure.zcml | 10 +- bika/lims/upgrade/v01_03_006.py | 72 ++++++++++++++ bika/lims/upgrade/v01_03_006.zcml | 22 +++++ 7 files changed, 170 insertions(+), 69 deletions(-) create mode 100644 bika/lims/upgrade/v01_03_006.zcml diff --git a/bika/lims/content/analysisrequest.py b/bika/lims/content/analysisrequest.py index 1e26cd24b1..5c56f94000 100644 --- a/bika/lims/content/analysisrequest.py +++ b/bika/lims/content/analysisrequest.py @@ -149,8 +149,6 @@ label=_("Contact"), render_own_label=True, size=20, - helper_js=("bika_widgets/referencewidget.js", - "++resource++bika.lims.js/contact.js"), description=_("The primary contact of this sample, " "who will receive notifications and publications " "via email"), @@ -175,10 +173,9 @@ ), ), - ReferenceField( + UIDReferenceField( 'CCContact', multiValued=1, - vocabulary_display_path_bound=sys.maxsize, allowed_types=('Contact',), referenceClass=HoldingReference, relationship='AnalysisRequestCCContact', @@ -227,7 +224,7 @@ ), ), - ReferenceField( + UIDReferenceField( 'Client', required=1, allowed_types=('Client',), @@ -256,7 +253,7 @@ # Field for the creation of Secondary Analysis Requests. # This field is meant to be displayed in AR Add form only. A viewlet exists # to inform the user this Analysis Request is secondary - ReferenceField( + UIDReferenceField( "PrimaryAnalysisRequest", allowed_types=("AnalysisRequest",), referenceClass=HoldingReference, @@ -296,7 +293,7 @@ ) ), - ReferenceField( + UIDReferenceField( 'Batch', allowed_types=('Batch',), relationship='AnalysisRequestBatch', @@ -327,13 +324,13 @@ {'columnName': 'getClientTitle', 'width': '30', 'label': _('Client'), 'align': 'left'}, ], - force_all = False, + force_all=False, ui_item="getId", showOn=True, ), ), - ReferenceField( + UIDReferenceField( 'SubGroup', required=False, allowed_types=('SubGroup',), @@ -366,7 +363,7 @@ ), ), - ReferenceField( + UIDReferenceField( 'Template', allowed_types=('ARTemplate',), referenceClass=HoldingReference, @@ -376,8 +373,10 @@ write_permission=FieldEditTemplate, widget=ReferenceWidget( label=_("Sample Template"), - description=_("The predefined values of the Sample template are set " - "in the request"), + description=_( + "The predefined values of the Sample template are set in the " + "request" + ), size=20, render_own_label=True, visible={ @@ -393,7 +392,7 @@ ), # TODO Remove Profile field (in singular) - ReferenceField( + UIDReferenceField( 'Profile', allowed_types=('AnalysisProfile',), referenceClass=HoldingReference, @@ -415,12 +414,11 @@ ), ), - ReferenceField( + UIDReferenceField( 'Profiles', multiValued=1, allowed_types=('AnalysisProfile',), referenceClass=HoldingReference, - vocabulary_display_path_bound=sys.maxsize, relationship='AnalysisRequestAnalysisProfiles', mode="rw", read_permission=View, @@ -584,7 +582,8 @@ ), ), - DateTimeField('DatePreserved', + DateTimeField( + "DatePreserved", mode="rw", read_permission=View, write_permission=FieldEditDatePreserved, @@ -600,7 +599,8 @@ }, ), ), - StringField('Preserver', + StringField( + "Preserver", required=0, mode="rw", read_permission=View, @@ -618,7 +618,8 @@ ), ), # TODO Sample cleanup - This comes from partition - DurationField('RetentionPeriod', + DurationField( + "RetentionPeriod", required=0, mode="r", read_permission=View, @@ -643,7 +644,7 @@ ), ), - ReferenceField( + UIDReferenceField( 'Specification', required=0, primary_bound=True, # field changes propagate to partitions @@ -695,7 +696,7 @@ widget=ComputedWidget(visible=False), ), - ReferenceField( + UIDReferenceField( 'PublicationSpecification', required=0, allowed_types='AnalysisSpec', @@ -706,7 +707,8 @@ widget=ReferenceWidget( label=_("Publication Specification"), description=_( - "Set the specification to be used before publishing a Sample."), + "Set the specification to be used before publishing a Sample." + ), size=20, render_own_label=True, visible={ @@ -898,7 +900,7 @@ ), # TODO Remove - Is this still necessary? - ReferenceField( + UIDReferenceField( 'DefaultContainerType', allowed_types=('ContainerType',), relationship='AnalysisRequestContainerType', @@ -970,7 +972,7 @@ ), ), - ReferenceField( + UIDReferenceField( 'Attachment', multiValued=1, allowed_types=('Attachment',), @@ -1005,9 +1007,8 @@ ) ), - ReferenceField( + UIDReferenceField( 'Invoice', - vocabulary_display_path_bound=sys.maxsize, allowed_types=('Invoice',), referenceClass=HoldingReference, relationship='AnalysisRequestInvoice', @@ -1246,7 +1247,7 @@ ), # The Primary Sample the current sample was detached from - ReferenceField( + UIDReferenceField( "DetachedFrom", allowed_types=("AnalysisRequest",), relationship="AnalysisRequestDetachedFrom", @@ -1261,7 +1262,7 @@ # The Analysis Request the current Analysis Request comes from because of # an invalidation of the former - ReferenceField( + UIDReferenceField( 'Invalidated', allowed_types=('AnalysisRequest',), relationship='AnalysisRequestRetracted', @@ -1274,14 +1275,6 @@ ), ), - # The Analysis Request that was automatically generated due to the - # invalidation of the current Analysis Request - ComputedField( - 'Retest', - expression="here.get_retest()", - widget=ComputedWidget(visible=False) - ), - # For comments or results interpretation # Old one, to be removed because of the incorporation of # ResultsInterpretationDepts (due to LIMS-1628) @@ -1350,7 +1343,7 @@ "clients."), format="radio", render_own_label=True, - visible={'add': 'edit',} + visible={'add': 'edit'} ), ), ) @@ -1669,7 +1662,7 @@ def getBillableItems(self): # Keywords of the contained services billable_service_keys = map( lambda s: s.getKeyword(), set(billable_profile_services)) - # The billable items contain billable profiles and single selected analyses + # Billable items contain billable profiles and single selected analyses billable_items = billable_profiles # Get the analyses to be billed exclude_rs = ["retracted", "rejected"] @@ -2002,10 +1995,10 @@ def getAnalysisServiceSettings(self, uid): sets = [adv] if 'hidden' in adv else [] # Created by using an AR Profile? - if not sets and self.getProfiles(): - adv = [] - adv += [profile.getAnalysisServiceSettings(uid) for profile in - self.getProfiles()] + profiles = self.getProfiles() + if not sets and profiles: + adv = [profile.getAnalysisServiceSettings(uid) for profile in + profiles] sets = adv if 'hidden' in adv[0] else [] return sets[0] if sets else {'uid': uid} @@ -2151,15 +2144,27 @@ def set_ARAttachment(self, value): "It can not hold an own value!") return None + def getRawRetest(self): + """Returns the UID of the Analysis Request that has been generated + automatically because of the retraction of the current Analysis Request + """ + relationship = self.getField("Invalidated").relationship + uids = get_backreferences(self, relationship=relationship) + return uids[0] if uids else None + + @deprecated("Use getRetest instead") def get_retest(self): - """Returns the Analysis Request automatically generated because of the - retraction of the current analysis request + """Returns the Analysis Request that has been generated automatically + because of the retraction of the current Analysis Request """ - relationship = "AnalysisRequestRetracted" - retest = self.getBackReferences(relationship=relationship) - if retest and len(retest) > 1: - logger.warn("More than one retest for {0}".format(self.getId())) - return retest and retest[0] or None + return self.getRetest() + + def getRetest(self): + """Returns the Analysis Request that has been generated automatically + because of the retraction of the current Analysis Request + """ + uid = self.getRawRetest() + return api.get_object_by_uid(uid, default=None) def getAncestors(self, all_ancestors=True): """Returns the ancestor(s) of this Analysis Request @@ -2208,8 +2213,8 @@ def getDescendantsUIDs(self): This method is used as metadata """ - rel_id = "AnalysisRequestParentAnalysisRequest" - return get_backreferences(self, relationship=rel_id) + relationship = self.getField("ParentAnalysisRequest").relationship + return get_backreferences(self, relationship=relationship) def isPartition(self): """Returns true if this Analysis Request is a partition @@ -2246,11 +2251,19 @@ def setParentAnalysisRequest(self, value): parent = self.getParentAnalysisRequest() alsoProvides(parent, IAnalysisRequestWithPartitions) + def getRawSecondaryAnalysisRequests(self): + """Returns the UIDs of the secondary Analysis Requests from this + Analysis Request + """ + relationship = self.getField("PrimaryAnalysisRequest").relationship + return get_backreferences(self, relationship) + def getSecondaryAnalysisRequests(self): """Returns the secondary analysis requests from this analysis request """ - relationship = "AnalysisRequestPrimaryAnalysisRequest" - return self.getBackReferences(relationship=relationship) + uids = self.getRawSecondaryAnalysisRequests() + uc = api.get_tool("uid_catalog") + return [api.get_object(brain) for brain in uc(UID=uids)] def setDateReceived(self, value): """Sets the date received to this analysis request and to secondary @@ -2394,10 +2407,10 @@ def process_inline_images(self, html): # convert relative URLs to absolute URLs # N.B. This is actually a TinyMCE issue, but hardcoded in Plone: - # https://www.tiny.cloud/docs/configure/url-handling/#relative_urls + # https://www.tiny.cloud/docs/configure/url-handling/#relative_urls image_sources = re.findall(IMG_SRC_RX, html) - # we need a trailing slash so that urljoin does not remove the last segment + # add a trailing slash so that urljoin doesn't remove the last segment base_url = "{}/".format(api.get_url(self)) for src in image_sources: diff --git a/bika/lims/content/attachment.py b/bika/lims/content/attachment.py index 67c100a623..105abde637 100644 --- a/bika/lims/content/attachment.py +++ b/bika/lims/content/attachment.py @@ -143,10 +143,9 @@ def getLinkedRequests(self): :returns: sorted list of ARs, where the latest AR comes first """ - rc = api.get_tool("reference_catalog") - refs = rc.getBackReferences(self, "AnalysisRequestAttachment") + uids = get_backreferences(self, "AnalysisRequestAttachment") # fetch the objects by UID and handle nonexisting UIDs gracefully - ars = map(lambda ref: api.get_object_by_uid(ref.sourceUID, None), refs) + ars = map(lambda uid: api.get_object_by_uid(uid, None), uids) # filter out None values (nonexisting UIDs) ars = filter(None, ars) # sort by physical path, so that attachments coming from an AR with a @@ -161,9 +160,9 @@ def getLinkedAnalyses(self): :returns: sorted list of ANs, where the latest AN comes first """ # Fetch the linked Analyses UIDs - refs = get_backreferences(self, "AnalysisAttachment") + uids = get_backreferences(self, "AnalysisAttachment") # fetch the objects by UID and handle nonexisting UIDs gracefully - ans = map(lambda uid: api.get_object_by_uid(uid, None), refs) + ans = map(lambda uid: api.get_object_by_uid(uid, None), uids) # filter out None values (nonexisting UIDs) ans = filter(None, ans) # sort by physical path, so that attachments coming from an AR with a diff --git a/bika/lims/idserver.py b/bika/lims/idserver.py index 5f49edaa13..2557e7e55b 100644 --- a/bika/lims/idserver.py +++ b/bika/lims/idserver.py @@ -179,8 +179,7 @@ def get_secondary_count(context, default=0): if not primary: return default - - return len(primary.getSecondaryAnalysisRequests()) + return len(primary.getRawSecondaryAnalysisRequests()) def is_ar(context): diff --git a/bika/lims/profiles/default/metadata.xml b/bika/lims/profiles/default/metadata.xml index c421b7ef2d..0b57ccc9a8 100644 --- a/bika/lims/profiles/default/metadata.xml +++ b/bika/lims/profiles/default/metadata.xml @@ -1,6 +1,6 @@ - 1.3.6 + 13601 profile-jarn.jsi18n:default profile-Products.ATExtensions:default diff --git a/bika/lims/upgrade/configure.zcml b/bika/lims/upgrade/configure.zcml index 7ecd4d1279..23c56fb219 100644 --- a/bika/lims/upgrade/configure.zcml +++ b/bika/lims/upgrade/configure.zcml @@ -3,6 +3,9 @@ xmlns:genericsetup="http://namespaces.zope.org/genericsetup" i18n_domain="senaite.core"> + + + - - diff --git a/bika/lims/upgrade/v01_03_006.py b/bika/lims/upgrade/v01_03_006.py index 0d41b4a5f6..f9450e7321 100644 --- a/bika/lims/upgrade/v01_03_006.py +++ b/bika/lims/upgrade/v01_03_006.py @@ -174,3 +174,75 @@ def is_orphan(uid): """ obj = api.get_object_by_uid(uid, None) return obj is None + + +def migrate_analysisrequest_referencefields(tool): + """Migrates the ReferenceField from AnalysisRequest to UIDReferenceField + """ + logger.info("Migrate ReferenceFields to UIDReferenceField ...") + field_names = [ + "Attachment", + "Batch", + "CCContact", + "Client", + "DefaultContainerType", + "DetachedFrom", + "Invalidated", + "Invoice", + "PrimaryAnalysisRequest", + "Profile", + "Profiles", + "PublicationSpecification", + "Specification", + "SubGroup", + "Template", + ] + + cat = api.get_tool(CATALOG_ANALYSIS_REQUEST_LISTING) + brains = cat(portal_type="AnalysisRequest") + total = len(brains) + for num, sample in enumerate(brains): + if num and num % 100 == 0: + logger.info("Processed samples: {}/{}".format(num, total)) + + if num and num % 1000 == 0: + # reduce memory size of the transaction + transaction.savepoint() + + # Migrate the reference fields for current sample + sample = api.get_object(sample) + migrate_reference_fields(sample, field_names) + + # Flush the object from memory + sample._p_deactivate() + + logger.info("Migrate ReferenceFields to UIDReferenceField [DONE]") + + +def migrate_reference_fields(obj, field_names): + """Migrates the reference fields with the names specified from the obj + """ + ref_tool = api.get_tool(REFERENCE_CATALOG) + for field_name in field_names: + + # Get the relationship id from field + field = obj.getField(field_name) + ref_id = getattr(field, "relationship", False) + if not ref_id: + logger.error("No relationship for field {}".format(field_name)) + + # Extract the referenced objects + references = obj.getRefs(relationship=ref_id) + if not references: + # Processed already or no referenced objects + continue + + # Re-assign the object directly to the field + if field.multiValued: + value = [api.get_uid(val) for val in references] + else: + value = api.get_uid(references[0]) + field.set(obj, value) + + # Remove this relationship from reference catalog + ref_tool.deleteReferences(obj, relationship=ref_id) diff --git a/bika/lims/upgrade/v01_03_006.zcml b/bika/lims/upgrade/v01_03_006.zcml new file mode 100644 index 0000000000..e309dccbc6 --- /dev/null +++ b/bika/lims/upgrade/v01_03_006.zcml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file From 4b5de0bb44a4cdc5c89a657a56c3d54a28c333d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Mon, 5 Jun 2023 12:44:13 +0200 Subject: [PATCH 06/14] Leave get_object_by_uid unchanged --- bika/lims/content/abstractanalysis.py | 5 ++--- bika/lims/content/analysisservice.py | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bika/lims/content/abstractanalysis.py b/bika/lims/content/abstractanalysis.py index 1a204c872c..60d51de68d 100644 --- a/bika/lims/content/abstractanalysis.py +++ b/bika/lims/content/abstractanalysis.py @@ -719,9 +719,8 @@ def getAllowedMethods(self): :rtype: list of Methods """ uids = self.getRawAllowedMethods() - brains = api.search({"UID": uids}, catalog="uid_catalog") - methods = [api.get_object(brain, default=None) for brain in brains] - return filter(None, methods) + objs = [api.get_object_by_uid(uid, default=None) for uid in uids] + return filter(None, objs) @security.public def getRawAllowedMethods(self): diff --git a/bika/lims/content/analysisservice.py b/bika/lims/content/analysisservice.py index 1a90f22607..ff5c18a5a3 100644 --- a/bika/lims/content/analysisservice.py +++ b/bika/lims/content/analysisservice.py @@ -496,6 +496,7 @@ def getAvailableMethodUIDs(self): Returns the UIDs of the available methods. it is used as a vocabulary to fill the selection list of 'Methods' field. """ + # N.B. we return a copy of the list to avoid accidental writes method_uids = self.getRawMethods()[:] if self.getInstrumentEntryOfResults(): for instrument in self.getInstruments(): From 3893695943971c299b5344ffbefd9f0bce83fbe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Mon, 5 Jun 2023 12:47:01 +0200 Subject: [PATCH 07/14] Changelog --- CHANGES.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.rst b/CHANGES.rst index 06e0828129..eb477f8493 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,7 @@ Changelog 1.3.6 (unreleased) ------------------ +- #2320 Performance: prioritize raw getter for AllowedMethods field (#2149 port) - #2308 Rely on fields when validating values on sample creation (#2307 port) - #2249 Fix APIError: None is not supported (upgrade 1.3.6) - #2198 Use portal as relative path for sticker icons (#2197 port) From f4f09fad2633d027404b12bebd6cfa8a6d8b0feb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Mon, 5 Jun 2023 13:45:37 +0200 Subject: [PATCH 08/14] Use api.get_object_by_uid with default value instead of get_object --- bika/lims/content/abstractanalysis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bika/lims/content/abstractanalysis.py b/bika/lims/content/abstractanalysis.py index fe03fca6b2..739dd80987 100644 --- a/bika/lims/content/abstractanalysis.py +++ b/bika/lims/content/abstractanalysis.py @@ -1189,7 +1189,7 @@ def getRetest(self): """Returns the retest that comes from this analysis, if any """ retest_uid = self.getRawRetest() - return api.get_object(retest_uid, default=None) + return api.get_object_by_uid(retest_uid, default=None) def isRetested(self): """Returns whether this analysis has been retested or not From 4dac034c503c73175b329eab7afdac22a56e14b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Mon, 5 Jun 2023 13:51:41 +0200 Subject: [PATCH 09/14] Changelog --- CHANGES.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.rst b/CHANGES.rst index eb477f8493..3f214dae57 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,7 @@ Changelog 1.3.6 (unreleased) ------------------ +- #2321 Improve the creation process of AT content types (#2150 port) - #2320 Performance: prioritize raw getter for AllowedMethods field (#2149 port) - #2308 Rely on fields when validating values on sample creation (#2307 port) - #2249 Fix APIError: None is not supported (upgrade 1.3.6) From aa16258f4ad0b5f691af9c4db6b5857855dd0360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Mon, 5 Jun 2023 13:57:48 +0200 Subject: [PATCH 10/14] Changelog --- CHANGES.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.rst b/CHANGES.rst index 3f214dae57..8470a101b0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,7 @@ Changelog 1.3.6 (unreleased) ------------------ +- #2322 Cleanup getUID functions to prevent unnecessary searches (#2163 port) - #2321 Improve the creation process of AT content types (#2150 port) - #2320 Performance: prioritize raw getter for AllowedMethods field (#2149 port) - #2308 Rely on fields when validating values on sample creation (#2307 port) From de7e3cd395cc71655e1c57a464fa60cfe6be05cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Mon, 5 Jun 2023 13:59:50 +0200 Subject: [PATCH 11/14] Changelog --- CHANGES.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.rst b/CHANGES.rst index 8470a101b0..15f7305f92 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,7 @@ Changelog 1.3.6 (unreleased) ------------------ +- #2323 Remove default_method from AnalysisRequest's Contact field (#2208 port) - #2322 Cleanup getUID functions to prevent unnecessary searches (#2163 port) - #2321 Improve the creation process of AT content types (#2150 port) - #2320 Performance: prioritize raw getter for AllowedMethods field (#2149 port) From 35a5d0de1c7625b7f83adb32d1e8ae8b3c14bb09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Mon, 5 Jun 2023 14:05:44 +0200 Subject: [PATCH 12/14] Changelog --- CHANGES.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES.rst b/CHANGES.rst index 15f7305f92..80c37c4129 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,7 @@ Changelog 1.3.6 (unreleased) ------------------ +- #2324 Migrate AnalysisRequest's ReferenceField to UIDReferenceField (#2209 port) - #2323 Remove default_method from AnalysisRequest's Contact field (#2208 port) - #2322 Cleanup getUID functions to prevent unnecessary searches (#2163 port) - #2321 Improve the creation process of AT content types (#2150 port) From f3b4d6e670f50ba4979b505288cbc529d471a727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Mon, 5 Jun 2023 14:40:33 +0200 Subject: [PATCH 13/14] Remove Profile field (stale) from AnalysisRequest --- bika/lims/content/analysisrequest.py | 23 -------------------- bika/lims/exportimport/setupdata/__init__.py | 10 ++++----- 2 files changed, 5 insertions(+), 28 deletions(-) diff --git a/bika/lims/content/analysisrequest.py b/bika/lims/content/analysisrequest.py index 5c56f94000..ce342c8ea3 100644 --- a/bika/lims/content/analysisrequest.py +++ b/bika/lims/content/analysisrequest.py @@ -391,29 +391,6 @@ ), ), - # TODO Remove Profile field (in singular) - UIDReferenceField( - 'Profile', - allowed_types=('AnalysisProfile',), - referenceClass=HoldingReference, - relationship='AnalysisRequestAnalysisProfile', - mode="rw", - read_permission=View, - write_permission=ModifyPortalContent, - widget=ReferenceWidget( - label=_("Analysis Profile"), - description=_("Analysis profiles apply a certain set of analyses"), - size=20, - render_own_label=True, - visible=False, - catalog_name='bika_setup_catalog', - base_query={"is_active": True, - "sort_on": "sortable_title", - "sort_order": "ascending"}, - showOn=False, - ), - ), - UIDReferenceField( 'Profiles', multiValued=1, diff --git a/bika/lims/exportimport/setupdata/__init__.py b/bika/lims/exportimport/setupdata/__init__.py index 2e877711a9..c0e2036c09 100644 --- a/bika/lims/exportimport/setupdata/__init__.py +++ b/bika/lims/exportimport/setupdata/__init__.py @@ -2292,13 +2292,13 @@ def Import(self): getFullname=row['CCContact_Fullname'])[0].getObject() obj.setCCContact(contact) if row['AnalysisProfile_title']: - profile = pc(portal_type="AnalysisProfile", - title=row['AnalysisProfile_title'].getObject()) - obj.setProfile(profile) + profiles = pc(portal_type="AnalysisProfile", + title=row['AnalysisProfile_title'])[0].getObject() + obj.setProfiles([profiles]) if row['ARTemplate_title']: template = pc(portal_type="ARTemplate", - title=row['ARTemplate_title'])[0].getObject() - obj.setProfile(template) + title=row['ARTemplate_title'])[0].getObject() + obj.setTemplate(template) obj.unmarkCreationFlag() From 9ec500086517ddddf51e3b85bc1b79e09f58eac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Puiggen=C3=A9?= Date: Mon, 5 Jun 2023 14:53:03 +0200 Subject: [PATCH 14/14] Improve performance of legacy AT `UIDReferenceField`'s getter (#2212 port) --- bika/lims/browser/fields/uidreferencefield.py | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/bika/lims/browser/fields/uidreferencefield.py b/bika/lims/browser/fields/uidreferencefield.py index 362a68b29e..d665ce1963 100644 --- a/bika/lims/browser/fields/uidreferencefield.py +++ b/bika/lims/browser/fields/uidreferencefield.py @@ -153,18 +153,27 @@ def get(self, context, **kwargs): :return: object or list of objects for multiValued fields. :rtype: BaseContent | list[BaseContent] """ - value = StringField.get(self, context, **kwargs) - if not value: - return [] if self.multiValued else None + uids = StringField.get(self, context, **kwargs) + if not isinstance(uids, list): + uids = [uids] + + # Do a direct search for all brains at once + uc = api.get_tool("uid_catalog") + references = uc(UID=uids) + + # Keep the original order of items + references = sorted(references, key=lambda it: uids.index(it.UID)) + + # Return objects by default + full_objects = kwargs.pop("full_objects", True) + if full_objects: + references = [api.get_object(ref) for ref in references] + if self.multiValued: - # Only return objects which actually exist; this is necessary here - # because there are no HoldingReferences. This opens the - # possibility that deletions leave hanging references. - ret = filter( - lambda x: x, [self.get_object(context, uid) for uid in value]) - else: - ret = self.get_object(context, value) - return ret + return references + elif references: + return references[0] + return None @security.public def getRaw(self, context, aslist=False, **kwargs):