bika.lims.api.get_review_history()   B
last analyzed

Complexity

Conditions 5

Size

Total Lines 37
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 19
dl 0
loc 37
rs 8.9833
c 0
b 0
f 0
cc 5
nop 2
1
# -*- coding: utf-8 -*-
2
#
3
# This file is part of SENAITE.CORE.
4
#
5
# SENAITE.CORE is free software: you can redistribute it and/or modify it under
6
# the terms of the GNU General Public License as published by the Free Software
7
# Foundation, version 2.
8
#
9
# This program is distributed in the hope that it will be useful, but WITHOUT
10
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12
# details.
13
#
14
# You should have received a copy of the GNU General Public License along with
15
# this program; if not, write to the Free Software Foundation, Inc., 51
16
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17
#
18
# Copyright 2018-2025 by it's authors.
19
# Some rights reserved, see README and LICENSE.
20
21
import copy
22
import json
23
import re
24
from collections import OrderedDict
25
from datetime import datetime
26
from datetime import timedelta
27
from itertools import groupby
28
29
import Missing
30
import six
31
from AccessControl.PermissionRole import rolesForPermissionOn
32
from AccessControl.Permissions import copy_or_move as CopyOrMove
33
from Acquisition import aq_base
34
from Acquisition import aq_inner
35
from Acquisition import aq_parent
36
from bika.lims import logger
37
from bika.lims.interfaces import IClient
38
from bika.lims.interfaces import IContact
39
from bika.lims.interfaces import ILabContact
40
from DateTime import DateTime
41
from OFS.event import ObjectWillBeMovedEvent
42
from plone import api as ploneapi
43
from plone.api.exc import InvalidParameterError
44
from plone.app.layout.viewlets.content import ContentHistoryView
45
from plone.behavior.interfaces import IBehaviorAssignable
46
from plone.behavior.registration import lookup_behavior_registration
47
from plone.dexterity.interfaces import IDexterityContent
48
from plone.dexterity.schema import SchemaInvalidatedEvent
49
from plone.dexterity.utils import addContentToContainer
50
from plone.dexterity.utils import createContent
51
from plone.dexterity.utils import resolveDottedName
52
from plone.i18n.normalizer.interfaces import IFileNameNormalizer
53
from plone.i18n.normalizer.interfaces import IIDNormalizer
54
from plone.memoize.volatile import DontCache
55
from Products.Archetypes.atapi import DisplayList
56
from Products.Archetypes.BaseObject import BaseObject
57
from Products.Archetypes.event import ObjectInitializedEvent
58
from Products.Archetypes.public import StringField
59
from Products.Archetypes.utils import mapply
60
from Products.CMFCore.interfaces import IFolderish
61
from Products.CMFCore.interfaces import ISiteRoot
62
from Products.CMFCore.permissions import DeleteObjects
63
from Products.CMFCore.permissions import ModifyPortalContent
64
from Products.CMFCore.permissions import View
65
from Products.CMFCore.utils import getToolByName
66
from Products.CMFCore.WorkflowCore import WorkflowException
67
from Products.CMFPlone.RegistrationTool import get_member_by_login_name
68
from Products.CMFPlone.utils import _createObjectByType
69
from Products.CMFPlone.utils import base_hasattr
70
from Products.CMFPlone.utils import safe_unicode
71
from Products.PlonePAS.tools.memberdata import MemberData
72
from Products.ZCatalog.interfaces import ICatalogBrain
73
from senaite.core.interfaces import ITemporaryObject
74
from zope import globalrequest
75
from zope.annotation.interfaces import IAttributeAnnotatable
76
from zope.component import getUtility
77
from zope.component import queryMultiAdapter
78
from zope.container.contained import notifyContainerModified
79
from zope.event import notify
80
from zope.i18n import translate
81
from zope.interface import Invalid
82
from zope.interface import alsoProvides
83
from zope.interface import directlyProvides
84
from zope.interface import noLongerProvides
85
from zope.lifecycleevent import ObjectMovedEvent
86
from zope.publisher.browser import TestRequest
87
from zope.schema import getFieldsInOrder
88
from zope.schema.interfaces import RequiredMissing
89
from zope.schema.interfaces import WrongType
90
from zope.security.interfaces import Unauthorized
91
92
"""SENAITE LIMS Framework API
93
94
Please see tests/doctests/API.rst for documentation.
95
96
Architecural Notes:
97
98
Please add only functions that do a single thing for a single object.
99
100
Good: `def get_foo(brain_or_object)`
101
Bad:  `def get_foos(list_of_brain_objects)`
102
103
Why?
104
105
Because it makes things more complex. You can always use a pattern like this to
106
achieve the same::
107
108
    >>> foos = map(get_foo, list_of_brain_objects)
109
110
Please add for all functions a descriptive test in tests/doctests/API.rst.
111
112
Thanks.
113
"""
114
115
_marker = object()
116
117
UID_RX = re.compile("[a-z0-9]{32}$")
118
119
UID_CATALOG = "uid_catalog"
120
PORTAL_CATALOG = "portal_catalog"
121
122
# fields that are not validated by the API
123
SKIP_VALIDATION_FIELDS = [
124
    "allow_discussion",
125
    "contributors",
126
    "creators",
127
    "effective",
128
    "exclude_from_nav",
129
    "expires",
130
    "language",
131
    "nextPreviousEnabled",
132
    "relatedItems",
133
    "rights",
134
    "subjects",
135
]
136
137
class APIError(Exception):
138
    """Base exception class for bika.lims errors."""
139
140
141
def get_portal():
142
    """Get the portal object
143
144
    :returns: Portal object
145
    """
146
    return ploneapi.portal.getSite()
147
148
149
def get_setup():
150
    """Fetch the `bika_setup` folder.
151
    """
152
    portal = get_portal()
153
    return portal.get("bika_setup")
154
155
156
def get_bika_setup():
157
    """Fetch the `bika_setup` folder.
158
    """
159
    return get_setup()
160
161
162
def get_senaite_setup():
163
    """Fetch the new DX `setup` folder.
164
    """
165
    portal = get_portal()
166
    return portal.get("setup")
167
168
169
def create(container, portal_type, *args, **kwargs):
170
    """Creates an object in Bika LIMS
171
172
    This code uses most of the parts from the TypesTool
173
    see: `Products.CMFCore.TypesTool._constructInstance`
174
175
    :param container: container
176
    :type container: ATContentType/DexterityContentType/CatalogBrain
177
    :param portal_type: The portal type to create, e.g. "Client"
178
    :type portal_type: string
179
    :param title: The title for the new content object
180
    :type title: string
181
    :returns: The new created object
182
    """
183
    from bika.lims.utils import tmpID
184
185
    tmp_id = tmpID()
186
    id = kwargs.pop("id", "")
187
    title = kwargs.pop("title", "")
188
189
    # get the fti
190
    types_tool = get_tool("portal_types")
191
    fti = types_tool.getTypeInfo(portal_type)
192
193
    if fti.product:
194
        # create the AT object
195
        obj = _createObjectByType(portal_type, container, id or tmp_id)
196
        # update the object with values
197
        edit(obj, check_permissions=False, title=title, **kwargs)
198
        # auto-id if required
199
        if obj._at_rename_after_creation:
200
            obj._renameAfterCreation(check_auto_id=True)
201
        # we are no longer under creation
202
        obj.unmarkCreationFlag()
203
        # notify that the object was created
204
        notify(ObjectInitializedEvent(obj))
205
    else:
206
        content = createContent(portal_type, **kwargs)
207
        content.id = id
208
        content.title = title
209
        obj = addContentToContainer(container, content)
210
211
    return obj
212
213
214
def copy_object(source, container=None, portal_type=None, *args, **kwargs):
215
    """Creates a copy of the source object. If container is None, creates the
216
    copy inside the same container as the source. If portal_type is specified,
217
    creates a new object of this type, and copies the values from source fields
218
    to the destination object. Field values sent as kwargs have priority over
219
    the field values from source.
220
221
    :param source: object from which create a copy
222
    :type source: ATContentType/DexterityContentType/CatalogBrain
223
    :param container: destination container
224
    :type container: ATContentType/DexterityContentType/CatalogBrain
225
    :param portal_type: destination portal type
226
    :returns: The new created object
227
    """
228
    # Prevent circular dependencies
229
    from security import check_permission
230
    # Use same container as source unless explicitly set
231
    source = get_object(source)
232
    if not container:
233
        container = get_parent(source)
234
235
    # Use same portal type as source unless explicitly set
236
    if not portal_type:
237
        portal_type = get_portal_type(source)
238
239
    # Extend the fields to skip with defaults
240
    skip = kwargs.pop("skip", [])
241
    skip = set(skip)
242
    skip.update([
243
        "Products.Archetypes.Field.ComputedField",
244
        "UID",
245
        "id",
246
        "allowDiscussion",
247
        "contributors",
248
        "creation_date",
249
        "creators",
250
        "effectiveDate",
251
        "expirationDate",
252
        "language",
253
        "location",
254
        "modification_date",
255
        "rights",
256
        "subject",
257
    ])
258
    # Build a dict for complexity reduction
259
    skip = dict([(item, True) for item in skip])
260
261
    # Update kwargs with the field values to copy from source
262
    fields = get_fields(source)
263
    for field_name, field in fields.items():
264
        # Prioritize field values passed as kwargs
265
        if field_name in kwargs:
266
            continue
267
        # Skip framework internal fields by name
268
        if skip.get(field_name, False):
269
            continue
270
        # Skip fields of non-suitable types
271
        if hasattr(field, "getType") and skip.get(field.getType(), False):
272
            continue
273
        # Skip readonly fields
274
        if getattr(field, "readonly", False):
275
            continue
276
        # Skip non-readable fields
277
        perm = getattr(field, "read_permission", View)
278
        if perm and not check_permission(perm, source):
279
            continue
280
281
        # do not wake-up objects unnecessarily
282
        if hasattr(field, "getRaw"):
283
            field_value = field.getRaw(source)
284
        elif hasattr(field, "get_raw"):
285
            field_value = field.get_raw(source)
286
        elif hasattr(field, "getAccessor"):
287
            accessor = field.getAccessor(source)
288
            field_value = accessor()
289
        else:
290
            field_value = field.get(source)
291
292
        # Do a hard copy of value if mutable type
293
        if isinstance(field_value, (list, dict, set)):
294
            field_value = copy.deepcopy(field_value)
295
        kwargs.update({field_name: field_value})
296
297
    # Create a copy
298
    return create(container, portal_type, *args, **kwargs)
299
300
301
def edit(obj, check_permissions=True, **kwargs):
302
    """Updates the values of object fields with the new values passed-in
303
    """
304
    # Prevent circular dependencies
305
    from security import check_permission
306
    fields = get_fields(obj)
307
    for name, value in kwargs.items():
308
        field = fields.get(name, None)
309
        if not field:
310
            continue
311
312
        # cannot update readonly fields
313
        readonly = getattr(field, "readonly", False)
314
        if readonly:
315
            raise ValueError("Field '{}' is readonly".format(name))
316
317
        # check field writable permission
318
        if check_permissions:
319
            perm = getattr(field, "write_permission", ModifyPortalContent)
320
            if perm and not check_permission(perm, obj):
321
                raise Unauthorized("Field '{}' is not writeable".format(name))
322
323
        # Set the value
324
        if hasattr(field, "getMutator"):
325
            mutator = field.getMutator(obj)
326
            mapply(mutator, value)
327
        else:
328
            field.set(obj, value)
329
330
331
def move_object(obj, destination, check_constraints=True):
332
    """Moves the object to the destination folder
333
334
    This function has the same effect as:
335
336
        id = obj.getId()
337
        cp = origin.manage_cutObjects(id)
338
        destination.manage_pasteObjects(cp)
339
340
    but with slightly better performance. The code is mostly grabbed from
341
    OFS.CopySupport.CopyContainer_pasteObjects
342
343
    :param obj: object to move to destination
344
    :type obj: ATContentType/DexterityContentType/CatalogBrain/UID
345
    :param destination: destination container
346
    :type destination: ATContentType/DexterityContentType/CatalogBrain/UID
347
    :param check_constraints: constraints and permissions must be checked
348
    :type check_constraints: bool
349
    :returns: The moved object
350
    """
351
    # prevent circular dependencies
352
    from bika.lims.api.security import check_permission
353
354
    obj = get_object(obj)
355
    destination = get_object(destination)
356
357
    # make sure the object is not moved into itself
358
    if obj == destination:
359
        raise ValueError("Cannot move object into itself: {}".format(obj))
360
361
    # do nothing if destination is the same as origin
362
    origin = get_parent(obj)
363
    if origin == destination:
364
        return obj
365
366
    if check_constraints:
367
368
        # check origin object has CopyOrMove permission
369
        if not check_permission(CopyOrMove, obj):
370
            raise Unauthorized("Cannot move {}".format(obj))
371
372
        # check if portal type is allowed in destination object
373
        portal_type = get_portal_type(obj)
374
        pt = get_tool("portal_types")
375
        ti = pt.getTypeInfo(destination)
376
        if not ti.allowType(portal_type):
377
            raise ValueError("Disallowed subobject type: %s" % portal_type)
378
379
    id = get_id(obj)
380
381
    # notify that the object will be copied to destination
382
    obj._notifyOfCopyTo(destination, op=1)  # noqa
383
384
    # notify that the object will be moved to destination
385
    notify(ObjectWillBeMovedEvent(obj, origin, id, destination, id))
386
387
    # effectively move the object from origin to destination
388
    delete(obj, check_permissions=check_constraints, suppress_events=True)
389
    obj = aq_base(obj)
390
    destination._setObject(id, obj, set_owner=0, suppress_events=True)  # noqa
391
    obj = destination._getOb(id)  # noqa
392
393
    # since we used "suppress_events=True", we need to manually notify that the
394
    # object was moved and containers modified. This also makes the objects to
395
    # be re-catalogued
396
    notify(ObjectMovedEvent(obj, origin, id, destination, id))
397
    notifyContainerModified(origin)
398
    notifyContainerModified(destination)
399
400
    # make ownership implicit if possible, so it acquires the permissions from
401
    # the container
402
    obj.manage_changeOwnershipType(explicit=0)
403
404
    return obj
405
406
407
def uncatalog_object(obj, recursive=False):
408
    """Un-catalog the object from all catalogs
409
410
    :param obj: object to un-catalog
411
    :param recursive: recursively uncatalog all child objects
412
    :type obj: ATContentType/DexterityContentType
413
    """
414
    # un-catalog from registered catalogs
415
    obj.unindexObject()
416
    # explicitly un-catalog from uid_catalog
417
    uid_catalog = get_tool("uid_catalog")
418
    # the uids of uid_catalog are relative paths to portal root
419
    # see Products.Archetypes.UIDCatalog.UIDResolver.catalog_object
420
    url = "/".join(obj.getPhysicalPath()[2:])
421
    uid_catalog.uncatalog_object(url)
422
423
    if recursive:
424
        for child in obj.objectValues():
425
            uncatalog_object(child, recursive=recursive)
426
427
428
def catalog_object(obj, recursive=False):
429
    """Re-catalog the object
430
431
    :param obj: object to catalog
432
    :param recursive: recursively catalog all child objects
433
    :type obj: ATContentType/DexterityContentType
434
    """
435
    if is_at_content(obj):
436
        # explicitly re-catalog AT types at uid_catalog (DX types are
437
        # automatically reindexed in UID catalog on reindexObject)
438
        uc = get_tool("uid_catalog")
439
        # the uids of uid_catalog are relative paths to portal root
440
        # see Products.Archetypes.UIDCatalog.UIDResolver.catalog_object
441
        url = "/".join(obj.getPhysicalPath()[2:])
442
        uc.catalog_object(obj, url)
443
    obj.reindexObject()
444
445
    if recursive:
446
        for child in obj.objectValues():
447
            catalog_object(child, recursive=recursive)
448
449
450
def delete(obj, check_permissions=True, suppress_events=False):
451
    """Deletes the given object
452
453
    :param obj: object to un-catalog
454
    :param check_permissions: whether delete permission must be checked
455
    :param suppress_events: whether ondelete events have to be fired
456
    :type obj: ATContentType/DexterityContentType
457
    """
458
    from security import check_permission
459
    if check_permissions and not check_permission(DeleteObjects, obj):
460
        raise Unauthorized("Do not have permissions to remove this object")
461
462
    # un-catalog the object from all catalogs (uid_catalog included)
463
    uncatalog_object(obj)
464
    # delete the object
465
    parent = get_parent(obj)
466
    parent._delObject(obj.getId(), suppress_events=suppress_events)
467
468
469
def get_tool(name, context=None, default=_marker):
470
    """Get a portal tool by name
471
472
    :param name: The name of the tool, e.g. `senaite_setup_catalog`
473
    :type name: string
474
    :param context: A portal object
475
    :type context: ATContentType/DexterityContentType/CatalogBrain
476
    :returns: Portal Tool
477
    """
478
479
    # Try first with the context
480
    if context is not None:
481
        try:
482
            context = get_object(context)
483
            return getToolByName(context, name)
484
        except (APIError, AttributeError) as e:
485
            # https://github.com/senaite/bika.lims/issues/396
486
            logger.warn("get_tool::getToolByName({}, '{}') failed: {} "
487
                        "-> falling back to plone.api.portal.get_tool('{}')"
488
                        .format(repr(context), name, repr(e), name))
489
            return get_tool(name, default=default)
490
491
    # Try with the plone api
492
    try:
493
        return ploneapi.portal.get_tool(name)
494
    except InvalidParameterError:
495
        if default is not _marker:
496
            if isinstance(default, six.string_types):
497
                return get_tool(default)
498
            return default
499
        fail("No tool named '%s' found." % name)
500
501
502
def fail(msg=None):
503
    """API LIMS Error
504
    """
505
    if msg is None:
506
        msg = "Reason not given."
507
    raise APIError("{}".format(msg))
508
509
510
def is_object(brain_or_object):
511
    """Check if the passed in object is a supported portal content object
512
513
    :param brain_or_object: A single catalog brain or content object
514
    :type brain_or_object: Portal Object
515
    :returns: True if the passed in object is a valid portal content
516
    """
517
    if is_portal(brain_or_object):
518
        return True
519
    if is_supermodel(brain_or_object):
520
        return True
521
    if is_at_content(brain_or_object):
522
        return True
523
    if is_dexterity_content(brain_or_object):
524
        return True
525
    if is_brain(brain_or_object):
526
        return True
527
    return False
528
529
530
def get_object(brain_object_uid, default=_marker):
531
    """Get the full content object
532
533
    :param brain_object_uid: A catalog brain or content object or uid
534
    :type brain_object_uid: PortalObject/ATContentType/DexterityContentType
535
    /CatalogBrain/basestring
536
    :returns: The full object
537
    """
538
    if is_uid(brain_object_uid):
539
        return get_object_by_uid(brain_object_uid, default=default)
540
    elif is_supermodel(brain_object_uid):
541
        return brain_object_uid.instance
542
    if not is_object(brain_object_uid):
543
        if default is _marker:
544
            fail("{} is not supported.".format(repr(brain_object_uid)))
545
        return default
546
    if is_brain(brain_object_uid):
547
        return brain_object_uid.getObject()
548
    return brain_object_uid
549
550
551
def is_portal(brain_or_object):
552
    """Checks if the passed in object is the portal root object
553
554
    :param brain_or_object: A single catalog brain or content object
555
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
556
    :returns: True if the object is the portal root object
557
    :rtype: bool
558
    """
559
    return ISiteRoot.providedBy(brain_or_object)
560
561
562
def is_brain(brain_or_object):
563
    """Checks if the passed in object is a portal catalog brain
564
565
    :param brain_or_object: A single catalog brain or content object
566
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
567
    :returns: True if the object is a catalog brain
568
    :rtype: bool
569
    """
570
    return ICatalogBrain.providedBy(brain_or_object)
571
572
573
def is_supermodel(brain_or_object):
574
    """Checks if the passed in object is a supermodel
575
576
    :param brain_or_object: A single catalog brain or content object
577
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
578
    :returns: True if the object is a catalog brain
579
    :rtype: bool
580
    """
581
    # avoid circular imports
582
    from senaite.app.supermodel.interfaces import ISuperModel
583
    return ISuperModel.providedBy(brain_or_object)
584
585
586
def is_dexterity_content(brain_or_object):
587
    """Checks if the passed in object is a dexterity content type
588
589
    :param brain_or_object: A single catalog brain or content object
590
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
591
    :returns: True if the object is a dexterity content type
592
    :rtype: bool
593
    """
594
    return IDexterityContent.providedBy(brain_or_object)
595
596
597
def is_at_content(brain_or_object):
598
    """Checks if the passed in object is an AT content type
599
600
    :param brain_or_object: A single catalog brain or content object
601
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
602
    :returns: True if the object is an AT content type
603
    :rtype: bool
604
    """
605
    return isinstance(brain_or_object, BaseObject)
606
607
608
def is_dx_type(portal_type):
609
    """Checks if the portal type is DX based
610
611
    :param portal_type: The portal type name to check
612
    :returns: True if the portal type is DX based
613
    """
614
    portal_types = get_tool("portal_types")
615
    fti = portal_types.getTypeInfo(portal_type)
616
    if fti.product:
617
        return False
618
    return True
619
620
621
def is_at_type(portal_type):
622
    """Checks if the portal type is AT based
623
624
    :param portal_type: The portal type name to check
625
    :returns: True if the portal type is AT based
626
    """
627
    return not is_dx_type(portal_type)
628
629
630
def is_folderish(brain_or_object):
631
    """Checks if the passed in object is folderish
632
633
    :param brain_or_object: A single catalog brain or content object
634
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
635
    :returns: True if the object is folderish
636
    :rtype: bool
637
    """
638
    if hasattr(brain_or_object, "is_folderish"):
639
        if callable(brain_or_object.is_folderish):
640
            return brain_or_object.is_folderish()
641
        return brain_or_object.is_folderish
642
    return IFolderish.providedBy(get_object(brain_or_object))
643
644
645
def get_portal_type(brain_or_object):
646
    """Get the portal type for this object
647
648
    :param brain_or_object: A single catalog brain or content object
649
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
650
    :returns: Portal type
651
    :rtype: string
652
    """
653
    if not is_object(brain_or_object):
654
        fail("{} is not supported.".format(repr(brain_or_object)))
655
    return brain_or_object.portal_type
656
657
658
def get_schema(brain_or_object):
659
    """Get the schema of the content
660
661
    :param brain_or_object: A single catalog brain or content object
662
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
663
    :returns: Schema object
664
    """
665
    obj = get_object(brain_or_object)
666
    if is_portal(obj):
667
        fail("get_schema can't return schema of portal root")
668
    if is_dexterity_content(obj):
669
        pt = get_tool("portal_types")
670
        fti = pt.getTypeInfo(obj.portal_type)
671
        return fti.lookupSchema()
672
    if is_at_content(obj):
673
        return obj.Schema()
674
    fail("{} has no Schema.".format(brain_or_object))
675
676
677
def get_behaviors(portal_type):
678
    """List all behaviors
679
680
    :param portal_type: DX portal type name
681
    """
682
    portal_types = get_tool("portal_types")
683
    fti = portal_types.getTypeInfo(portal_type)
684
    if fti.product:
685
        raise TypeError("Expected DX type, got AT type instead.")
686
    return fti.behaviors
687
688
689
def enable_behavior(portal_type, behavior_id):
690
    """Enable behavior
691
692
    :param portal_type: DX portal type name
693
    :param behavior_id: The behavior to enable
694
    """
695
    portal_types = get_tool("portal_types")
696
    fti = portal_types.getTypeInfo(portal_type)
697
    if fti.product:
698
        raise TypeError("Expected DX type, got AT type instead.")
699
700
    if behavior_id not in fti.behaviors:
701
        fti.behaviors += (behavior_id, )
702
        # invalidate schema cache
703
        notify(SchemaInvalidatedEvent(portal_type))
704
705
706
def disable_behavior(portal_type, behavior_id):
707
    """Disable behavior
708
709
    :param portal_type: DX portal type name
710
    :param behavior_id: The behavior to disable
711
    """
712
    portal_types = get_tool("portal_types")
713
    fti = portal_types.getTypeInfo(portal_type)
714
    if fti.product:
715
        raise TypeError("Expected DX type, got AT type instead.")
716
    fti.behaviors = tuple(filter(lambda b: b != behavior_id, fti.behaviors))
717
    # invalidate schema cache
718
    notify(SchemaInvalidatedEvent(portal_type))
719
720
721
def get_fields(brain_or_object):
722
    """Get a name to field mapping of the object
723
724
    :param brain_or_object: A single catalog brain or content object
725
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
726
    :returns: Mapping of name -> field
727
    :rtype: OrderedDict
728
    """
729
    obj = get_object(brain_or_object)
730
    schema = get_schema(obj)
731
    if is_dexterity_content(obj):
732
        # get the fields directly provided by the interface
733
        fields = getFieldsInOrder(schema)
734
        # append the fields coming from behaviors
735
        behavior_assignable = IBehaviorAssignable(obj)
736
        if behavior_assignable:
737
            behaviors = behavior_assignable.enumerateBehaviors()
738
            for behavior in behaviors:
739
                fields.extend(getFieldsInOrder(behavior.interface))
740
        return OrderedDict(fields)
741
    return OrderedDict(schema)
742
743
744
def get_id(brain_or_object):
745
    """Get the Plone ID for this object
746
747
    :param brain_or_object: A single catalog brain or content object
748
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
749
    :returns: Plone ID
750
    :rtype: string
751
    """
752
    if is_brain(brain_or_object):
753
        if base_hasattr(brain_or_object, "getId"):
754
            return brain_or_object.getId
755
        if base_hasattr(brain_or_object, "id"):
756
            return brain_or_object.id
757
    return get_object(brain_or_object).getId()
758
759
760
def get_title(brain_or_object):
761
    """Get the Title for this object
762
763
    :param brain_or_object: A single catalog brain or content object
764
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
765
    :returns: Title
766
    :rtype: string
767
    """
768
    if is_brain(brain_or_object) and base_hasattr(brain_or_object, "Title"):
769
        return brain_or_object.Title
770
    return get_object(brain_or_object).Title()
771
772
773
def get_description(brain_or_object):
774
    """Get the Title for this object
775
776
    :param brain_or_object: A single catalog brain or content object
777
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
778
    :returns: Title
779
    :rtype: string
780
    """
781
    if is_brain(brain_or_object) \
782
            and base_hasattr(brain_or_object, "Description"):
783
        return brain_or_object.Description
784
    return get_object(brain_or_object).Description()
785
786
787
def get_uid(brain_or_object):
788
    """Get the Plone UID for this object
789
790
    :param brain_or_object: A single catalog brain or content object or an UID
791
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
792
    :returns: Plone UID
793
    :rtype: string
794
    """
795
    if is_uid(brain_or_object):
796
        return brain_or_object
797
    if is_portal(brain_or_object):
798
        return '0'
799
    if is_brain(brain_or_object) and base_hasattr(brain_or_object, "UID"):
800
        return brain_or_object.UID
801
    return get_object(brain_or_object).UID()
802
803
804
def get_url(brain_or_object):
805
    """Get the absolute URL for this object
806
807
    :param brain_or_object: A single catalog brain or content object
808
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
809
    :returns: Absolute URL
810
    :rtype: string
811
    """
812
    if is_brain(brain_or_object) and base_hasattr(brain_or_object, "getURL"):
813
        return brain_or_object.getURL()
814
    return get_object(brain_or_object).absolute_url()
815
816
817
def get_icon(thing, html_tag=True):
818
    """Get the icon of the content object
819
820
    :param thing: A single catalog brain, content object or portal_type
821
    :type thing: ATContentType/DexterityContentType/CatalogBrain/String
822
    :param html_tag: A value of 'True' returns the HTML tag, else the image url
823
    :type html_tag: bool
824
    :returns: HTML '<img>' tag if 'html_tag' is True else the image url
825
    :rtype: string
826
    """
827
    portal_type = thing
828
    if is_object(thing):
829
        portal_type = get_portal_type(thing)
830
831
    # Manual approach, because `plone.app.layout.getIcon` does not reliable
832
    # work for Contents coming from other catalogs than the
833
    # `portal_catalog`
834
    portal_types = get_tool("portal_types")
835
    fti = portal_types.getTypeInfo(portal_type)
836
    if not fti:
837
        fail("No type info for {}".format(repr(thing)))
838
    icon = fti.getIcon()
839
    if not icon:
840
        return ""
841
    url = "%s/%s" % (get_url(get_portal()), icon)
842
    if not html_tag:
843
        return url
844
845
    # build the img element
846
    if is_object(thing):
847
        title = get_title(thing)
848
    else:
849
        title = fti.Title()
850
    tag = '<img width="16" height="16" src="{url}" title="{title}" />'
851
    return tag.format(url=url, title=title)
852
853
854
def get_object_by_uid(uid, default=_marker):
855
    """Find an object by a given UID
856
857
    :param uid: The UID of the object to find
858
    :type uid: string
859
    :returns: Found Object or None
860
    """
861
862
    # nothing to do here
863
    if not uid:
864
        if default is not _marker:
865
            return default
866
        fail("get_object_by_uid requires UID as first argument; got {} instead"
867
             .format(uid))
868
869
    # we defined the portal object UID to be '0'::
870
    if uid == '0':
871
        return get_portal()
872
873
    brain = get_brain_by_uid(uid)
874
875
    if brain is None:
876
        if default is not _marker:
877
            return default
878
        fail("No object found for UID {}".format(uid))
879
880
    return get_object(brain)
881
882
883
def get_brain_by_uid(uid, default=None):
884
    """Query a brain by a given UID
885
886
    :param uid: The UID of the object to find
887
    :type uid: string
888
    :returns: ZCatalog brain or None
889
    """
890
    if not is_uid(uid):
891
        return default
892
893
    # we try to find the object with the UID catalog
894
    uc = get_tool(UID_CATALOG)
895
896
    # try to find the object with the reference catalog first
897
    brains = uc(UID=uid)
898
    if len(brains) != 1:
899
        return default
900
    return brains[0]
901
902
903
def get_object_by_path(path, default=_marker):
904
    """Find an object by a given physical path or absolute_url
905
906
    :param path: The physical path of the object to find
907
    :type path: string
908
    :returns: Found Object or None
909
    """
910
911
    # nothing to do here
912
    if not path:
913
        if default is not _marker:
914
            return default
915
        fail("get_object_by_path first argument must be a path; {} received"
916
             .format(path))
917
918
    portal = get_portal()
919
    portal_path = get_path(portal)
920
    portal_url = get_url(portal)
921
922
    # ensure we have a physical path
923
    if path.startswith(portal_url):
924
        request = get_request()
925
        path = "/".join(request.physicalPathFromURL(path))
926
927
    if not path.startswith(portal_path):
928
        if default is not _marker:
929
            return default
930
        fail("Not a physical path inside the portal.")
931
932
    if path == portal_path:
933
        return portal
934
935
    return portal.restrictedTraverse(path, default)
936
937
938
def get_path(brain_or_object):
939
    """Calculate the physical path of this object
940
941
    :param brain_or_object: A single catalog brain or content object
942
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
943
    :returns: Physical path of the object
944
    :rtype: string
945
    """
946
    if is_brain(brain_or_object):
947
        return brain_or_object.getPath()
948
    return "/".join(get_object(brain_or_object).getPhysicalPath())
949
950
951
def get_parent_path(brain_or_object):
952
    """Calculate the physical parent path of this object
953
954
    :param brain_or_object: A single catalog brain or content object
955
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
956
    :returns: Physical path of the parent object
957
    :rtype: string
958
    """
959
    if is_portal(brain_or_object):
960
        return get_path(get_portal())
961
    if is_brain(brain_or_object):
962
        path = get_path(brain_or_object)
963
        return path.rpartition("/")[0]
964
    return get_path(get_object(brain_or_object).aq_parent)
965
966
967
def get_parent(brain_or_object, **kw):
968
    """Locate the parent object of the content/catalog brain
969
970
    :param brain_or_object: A single catalog brain or content object
971
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
972
    :param catalog_search: Use a catalog query to find the parent object
973
    :type catalog_search: bool
974
    :returns: parent object
975
    :rtype: ATContentType/DexterityContentType/PloneSite/CatalogBrain
976
    """
977
978
    if is_portal(brain_or_object):
979
        return get_portal()
980
981
    # BBB: removed `catalog_search` keyword
982
    if kw:
983
        logger.warn("API function `get_parent` no longer support keywords.")
984
985
    return get_object(brain_or_object).aq_parent
986
987
988
def search(query, catalog=_marker):
989
    """Search for objects.
990
991
    :param query: A suitable search query.
992
    :type query: dict
993
    :param catalog: A single catalog id or a list of catalog ids
994
    :type catalog: str/list
995
    :returns: Search results
996
    :rtype: List of ZCatalog brains
997
    """
998
999
    # query needs to be a dictionary
1000
    if not isinstance(query, dict):
1001
        fail("Catalog query needs to be a dictionary")
1002
1003
    # Portal types to query
1004
    portal_types = query.get("portal_type", [])
1005
    # We want the portal_type as a list
1006
    if not isinstance(portal_types, (tuple, list)):
1007
        portal_types = [portal_types]
1008
1009
    # The catalogs used for the query
1010
    catalogs = []
1011
1012
    # The user did **not** specify a catalog
1013
    if catalog is _marker:
1014
        # Find the registered catalogs for the queried portal types
1015
        for portal_type in portal_types:
1016
            # Just get the first registered/default catalog
1017
            catalogs.append(get_catalogs_for(
1018
                portal_type, default=UID_CATALOG)[0])
1019
    else:
1020
        # User defined catalogs
1021
        if isinstance(catalog, (list, tuple)):
1022
            catalogs.extend(map(get_tool, catalog))
1023
        else:
1024
            catalogs.append(get_tool(catalog))
1025
1026
    # Cleanup: Avoid duplicate catalogs
1027
    catalogs = list(set(catalogs)) or [get_uid_catalog()]
1028
1029
    # We only support **single** catalog queries
1030
    if len(catalogs) > 1:
1031
        fail("Multi Catalog Queries are not supported!")
1032
1033
    return catalogs[0](query)
1034
1035
1036
def safe_getattr(brain_or_object, attr, default=_marker):
1037
    """Return the attribute value
1038
1039
    :param brain_or_object: A single catalog brain or content object
1040
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
1041
    :param attr: Attribute name
1042
    :type attr: str
1043
    :returns: Attribute value
1044
    :rtype: obj
1045
    """
1046
    try:
1047
        value = getattr(brain_or_object, attr, _marker)
1048
        if value is _marker:
1049
            if default is not _marker:
1050
                return default
1051
            fail("Attribute '{}' not found.".format(attr))
1052
        if callable(value):
1053
            return value()
1054
        return value
1055
    except Unauthorized:
1056
        if default is not _marker:
1057
            return default
1058
        fail("You are not authorized to access '{}' of '{}'.".format(
1059
            attr, repr(brain_or_object)))
1060
1061
1062
def get_uid_catalog():
1063
    """Get the UID catalog tool
1064
1065
    :returns: UID Catalog Tool
1066
    """
1067
    return get_tool(UID_CATALOG)
1068
1069
1070
def get_portal_catalog():
1071
    """Get the portal catalog tool
1072
1073
    :returns: Portal Catalog Tool
1074
    """
1075
    return get_tool(PORTAL_CATALOG)
1076
1077
1078
def get_review_history(brain_or_object, rev=True):
1079
    """Get the review history for the given brain or context.
1080
1081
    :param brain_or_object: A single catalog brain or content object
1082
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
1083
    :returns: Workflow history
1084
    :rtype: [{}, ...]
1085
    """
1086
    obj = get_object(brain_or_object)
1087
    review_history = []
1088
    try:
1089
        workflow = get_tool("portal_workflow")
1090
        review_history = workflow.getInfoFor(obj, 'review_history')
1091
    except WorkflowException as e:
1092
        message = str(e)
1093
        logger.error("Cannot retrieve review_history on {}: {}".format(
1094
            obj, message))
1095
    if not isinstance(review_history, (list, tuple)):
1096
        logger.error("get_review_history: expected list, received {}".format(
1097
            review_history))
1098
        review_history = []
1099
1100
    if isinstance(review_history, tuple):
1101
        # Products.CMFDefault.DefaultWorkflow.getInfoFor always returns a
1102
        # tuple when "review_history" is passed in:
1103
        # https://github.com/zopefoundation/Products.CMFDefault/blob/master/Products/CMFDefault/DefaultWorkflow.py#L244
1104
        #
1105
        # On the other hand, Products.DCWorkflow.getInfoFor relies on
1106
        # Expression.StateChangeInfo, that always returns a list, except when no
1107
        # review_history is found:
1108
        # https://github.com/zopefoundation/Products.DCWorkflow/blob/master/Products/DCWorkflow/DCWorkflow.py#L310
1109
        # https://github.com/zopefoundation/Products.DCWorkflow/blob/master/Products/DCWorkflow/Expression.py#L94
1110
        review_history = list(review_history)
1111
1112
    if rev is True:
1113
        review_history.reverse()
1114
    return review_history
1115
1116
1117
def get_revision_history(brain_or_object):
1118
    """Get the revision history for the given brain or context.
1119
1120
    :param brain_or_object: A single catalog brain or content object
1121
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
1122
    :returns: Workflow history
1123
    :rtype: obj
1124
    """
1125
    obj = get_object(brain_or_object)
1126
    chv = ContentHistoryView(obj, safe_getattr(obj, "REQUEST", None))
1127
    return chv.fullHistory()
1128
1129
1130
def get_workflows_for(brain_or_object):
1131
    """Get the assigned workflows for the given brain or context.
1132
1133
    Note: This function supports also the portal_type as parameter.
1134
1135
    :param brain_or_object: A single catalog brain or content object
1136
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
1137
    :returns: Assigned Workflows
1138
    :rtype: tuple
1139
    """
1140
    workflow = ploneapi.portal.get_tool("portal_workflow")
1141
    if isinstance(brain_or_object, six.string_types):
1142
        return workflow.getChainFor(brain_or_object)
1143
    obj = get_object(brain_or_object)
1144
    return workflow.getChainFor(obj)
1145
1146
1147
def get_workflow_status_of(brain_or_object, state_var="review_state"):
1148
    """Get the current workflow status of the given brain or context.
1149
1150
    :param brain_or_object: A single catalog brain or content object
1151
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
1152
    :param state_var: The name of the state variable
1153
    :type state_var: string
1154
    :returns: Status
1155
    :rtype: str
1156
    """
1157
    # Try to get the state from the catalog brain first
1158
    if is_brain(brain_or_object):
1159
        if state_var in brain_or_object.schema():
1160
            return brain_or_object[state_var]
1161
1162
    # Retrieve the sate from the object
1163
    workflow = get_tool("portal_workflow")
1164
    obj = get_object(brain_or_object)
1165
    return workflow.getInfoFor(ob=obj, name=state_var, default='')
1166
1167
1168
def get_previous_worfklow_status_of(brain_or_object, skip=None, default=None):
1169
    """Get the previous workflow status of the object
1170
1171
    :param brain_or_object: A single catalog brain or content object
1172
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
1173
    :param skip: Workflow states to skip
1174
    :type skip: tuple/list
1175
    :returns: status
1176
    :rtype: str
1177
    """
1178
1179
    skip = isinstance(skip, (list, tuple)) and skip or []
1180
    history = get_review_history(brain_or_object)
1181
1182
    # Remove consecutive duplicates, some transitions might happen more than
1183
    # once consecutively (e.g. publish)
1184
    history = map(lambda i: i[0], groupby(history))
1185
1186
    for num, item in enumerate(history):
1187
        # skip the current history entry
1188
        if num == 0:
1189
            continue
1190
        status = item.get("review_state")
1191
        if status in skip:
1192
            continue
1193
        return status
1194
    return default
1195
1196
1197
def get_creation_date(brain_or_object):
1198
    """Get the creation date of the brain or object
1199
1200
    :param brain_or_object: A single catalog brain or content object
1201
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
1202
    :returns: Creation date
1203
    :rtype: DateTime
1204
    """
1205
    created = getattr(brain_or_object, "created", None)
1206
    if created is None:
1207
        fail("Object {} has no creation date ".format(
1208
             repr(brain_or_object)))
1209
    if callable(created):
1210
        return created()
1211
    return created
1212
1213
1214
def get_modification_date(brain_or_object):
1215
    """Get the modification date of the brain or object
1216
1217
    :param brain_or_object: A single catalog brain or content object
1218
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
1219
    :returns: Modification date
1220
    :rtype: DateTime
1221
    """
1222
    modified = getattr(brain_or_object, "modified", None)
1223
    if modified is None:
1224
        fail("Object {} has no modification date ".format(
1225
             repr(brain_or_object)))
1226
    if callable(modified):
1227
        return modified()
1228
    return modified
1229
1230
1231
def get_review_status(brain_or_object):
1232
    """Get the `review_state` of an object
1233
1234
    :param brain_or_object: A single catalog brain or content object
1235
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
1236
    :returns: Value of the review_status variable
1237
    :rtype: String
1238
    """
1239
    if is_brain(brain_or_object) \
1240
       and base_hasattr(brain_or_object, "review_state"):
1241
        return brain_or_object.review_state
1242
    return get_workflow_status_of(brain_or_object, state_var="review_state")
1243
1244
1245
def is_active(brain_or_object):
1246
    """Check if the workflow state of the object is 'inactive' or 'cancelled'.
1247
1248
    :param brain_or_object: A single catalog brain or content object
1249
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
1250
    :returns: False if the object is in the state 'inactive' or 'cancelled'
1251
    :rtype: bool
1252
    """
1253
    if get_review_status(brain_or_object) in ["cancelled", "inactive"]:
1254
        return False
1255
    return True
1256
1257
1258
def get_fti(portal_type, default=None):
1259
    """Lookup the Dynamic Filetype Information for the given portal_type
1260
1261
    :param portal_type: The portal type to get the FTI for
1262
    :returns: FTI or default value
1263
    """
1264
    if not is_string(portal_type):
1265
        return default
1266
    portal_types = get_tool("portal_types")
1267
    fti = portal_types.getTypeInfo(portal_type)
1268
    return fti or default
1269
1270
1271
def get_catalogs_for(brain_or_object, default=PORTAL_CATALOG):
1272
    """Get all registered catalogs for the given portal_type, catalog brain or
1273
    content object
1274
1275
    NOTE: We pass in the `portal_catalog` as default in subsequent calls to
1276
          work around the missing `uid_catalog` during snapshot creation when
1277
          installing a fresh site!
1278
1279
    :param brain_or_object: The portal_type, a catalog brain or content object
1280
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
1281
    :returns: List of supported catalogs
1282
    :rtype: list
1283
    """
1284
1285
    # only handle catalog lookups by portal_type internally
1286
    if is_uid(brain_or_object) or is_object(brain_or_object):
1287
        obj = get_object(brain_or_object)
1288
        portal_type = get_portal_type(obj)
1289
        return get_catalogs_for(portal_type)
1290
1291
    catalogs = []
1292
1293
    if not is_string(brain_or_object):
1294
        raise APIError("Expected a portal_type string, got <%s>"
1295
                       % type(brain_or_object))
1296
1297
    # at this point the brain_or_object is a portal_type
1298
    portal_type = brain_or_object
1299
1300
    # check static portal_type -> catalog mapping first
1301
    from senaite.core.catalog import get_catalogs_by_type
1302
    catalogs = get_catalogs_by_type(portal_type)
1303
1304
    # no catalogs in static mapping
1305
    # => Lookup catalogs by FTI
1306
    if len(catalogs) == 0:
1307
        fti = get_fti(portal_type)
1308
        if fti.product:
1309
            # AT content type
1310
            # => Looup via archetype_tool
1311
            archetype_tool = get_tool("archetype_tool")
1312
            catalogs = archetype_tool.catalog_map.get(portal_type) or []
1313
        else:
1314
            # DX content type
1315
            # => resolve the `_catalogs` attribute from the class
1316
            klass = resolveDottedName(fti.klass)
1317
            # XXX: Refactor multi-catalog behavior to not rely
1318
            #      on this hidden `_catalogs` attribute!
1319
            catalogs = getattr(klass, "_catalogs", [])
1320
1321
    # fetch the catalog objects
1322
    catalogs = filter(None, map(lambda cid: get_tool(cid, None), catalogs))
1323
1324
    if len(catalogs) == 0:
1325
        return [get_tool(default, default=PORTAL_CATALOG)]
1326
1327
    return list(catalogs)
1328
1329
1330
def get_transitions_for(brain_or_object):
1331
    """List available workflow transitions for all workflows
1332
1333
    :param brain_or_object: A single catalog brain or content object
1334
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
1335
    :returns: All possible available and allowed transitions
1336
    :rtype: list[dict]
1337
    """
1338
    workflow = get_tool('portal_workflow')
1339
    transitions = []
1340
    instance = get_object(brain_or_object)
1341
    for wfid in get_workflows_for(brain_or_object):
1342
        wf = workflow[wfid]
1343
        tlist = wf.getTransitionsFor(instance)
1344
        transitions.extend([t for t in tlist if t not in transitions])
1345
    return transitions
1346
1347
1348
def do_transition_for(brain_or_object, transition):
1349
    """Performs a workflow transition for the passed in object.
1350
1351
    :param brain_or_object: A single catalog brain or content object
1352
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
1353
    :returns: The object where the transtion was performed
1354
    """
1355
    if not isinstance(transition, six.string_types):
1356
        fail("Transition type needs to be string, got '%s'" % type(transition))
1357
    obj = get_object(brain_or_object)
1358
    try:
1359
        ploneapi.content.transition(obj, transition)
1360
    except ploneapi.exc.InvalidParameterError as e:
1361
        fail("Failed to perform transition '{}' on {}: {}".format(
1362
             transition, obj, str(e)))
1363
    return obj
1364
1365
1366
def get_roles_for_permission(permission, brain_or_object):
1367
    """Get a list of granted roles for the given permission on the object.
1368
1369
    :param brain_or_object: A single catalog brain or content object
1370
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
1371
    :returns: Roles for the given Permission
1372
    :rtype: list
1373
    """
1374
    obj = get_object(brain_or_object)
1375
    allowed = set(rolesForPermissionOn(permission, obj))
1376
    return sorted(allowed)
1377
1378
1379
def is_versionable(brain_or_object, policy='at_edit_autoversion'):
1380
    """Checks if the passed in object is versionable.
1381
1382
    :param brain_or_object: A single catalog brain or content object
1383
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
1384
    :returns: True if the object is versionable
1385
    :rtype: bool
1386
    """
1387
    pr = get_tool("portal_repository")
1388
    obj = get_object(brain_or_object)
1389
    return pr.supportsPolicy(obj, 'at_edit_autoversion') \
1390
        and pr.isVersionable(obj)
1391
1392
1393
def get_version(brain_or_object):
1394
    """Get the version of the current object
1395
1396
    :param brain_or_object: A single catalog brain or content object
1397
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
1398
    :returns: The current version of the object, or None if not available
1399
    :rtype: int or None
1400
    """
1401
    obj = get_object(brain_or_object)
1402
    if not is_versionable(obj):
1403
        return None
1404
    return getattr(aq_base(obj), "version_id", 0)
1405
1406
1407
def get_view(name, context=None, request=None, default=None):
1408
    """Get the view by name
1409
1410
    :param name: The name of the view
1411
    :type name: str
1412
    :param context: The context to query the view
1413
    :type context: ATContentType/DexterityContentType/CatalogBrain
1414
    :param request: The request to query the view
1415
    :type request: HTTPRequest object
1416
    :returns: HTTP Request
1417
    :rtype: Products.Five.metaclass View object
1418
    """
1419
    context = context or get_portal()
1420
    request = request or get_request() or None
1421
    view = queryMultiAdapter((get_object(context), request), name=name)
1422
    if view is None:
1423
        return default
1424
    return view
1425
1426
1427
def get_request():
1428
    """Get the global request object
1429
1430
    :returns: HTTP Request
1431
    :rtype: HTTPRequest object
1432
    """
1433
    return globalrequest.getRequest()
1434
1435
1436
def get_test_request():
1437
    """Get the TestRequest object
1438
    """
1439
    request = TestRequest()
1440
    directlyProvides(request, IAttributeAnnotatable)
1441
    return request
1442
1443
1444
def get_group(group_or_groupname):
1445
    """Return Plone Group
1446
1447
    :param group_or_groupname: Plone group or the name of the group
1448
    :type groupname:  GroupData/str
1449
    :returns: Plone GroupData
1450
    """
1451
    if not group_or_groupname:
1452
1453
        return None
1454
    if hasattr(group_or_groupname, "_getGroup"):
1455
        return group_or_groupname
1456
    gtool = get_tool("portal_groups")
1457
    return gtool.getGroupById(group_or_groupname)
1458
1459
1460
def get_user(user_or_username):
1461
    """Return Plone User
1462
1463
    :param user_or_username: Plone user or user id
1464
    :returns: Plone MemberData
1465
    """
1466
    user = None
1467
    if isinstance(user_or_username, MemberData):
1468
        user = user_or_username
1469
    if isinstance(user_or_username, six.string_types):
1470
        user = get_member_by_login_name(get_portal(), user_or_username, False)
1471
    return user
1472
1473
1474
def get_user_properties(user_or_username):
1475
    """Return User Properties
1476
1477
    :param user_or_username: Plone group identifier
1478
    :returns: Plone MemberData
1479
    """
1480
    user = get_user(user_or_username)
1481
    if user is None:
1482
        return {}
1483
    if not callable(user.getUser):
1484
        return {}
1485
    out = {}
1486
    plone_user = user.getUser()
1487
    for sheet in plone_user.listPropertysheets():
1488
        ps = plone_user.getPropertysheet(sheet)
1489
        out.update(dict(ps.propertyItems()))
1490
    return out
1491
1492
1493
def get_users_by_roles(roles=None):
1494
    """Search Plone users by their roles
1495
1496
    :param roles: Plone role name or list of roles
1497
    :type roles:  list/str
1498
    :returns: List of Plone users having the role(s)
1499
    """
1500
    if not isinstance(roles, (tuple, list)):
1501
        roles = [roles]
1502
    mtool = get_tool("portal_membership")
1503
    return mtool.searchForMembers(roles=roles)
1504
1505
1506
def get_current_user():
1507
    """Returns the current logged in user
1508
1509
    :returns: Current User
1510
    """
1511
    return ploneapi.user.get_current()
1512
1513
1514
def get_user_contact(user, contact_types=['Contact', 'LabContact']):
1515
    """Returns the associated contact of a Plone user
1516
1517
    If the user passed in has no contact associated, return None.
1518
    The `contact_types` parameter filter the portal types for the search.
1519
1520
    :param: Plone user
1521
    :contact_types: List with the contact portal types to search
1522
    :returns: Contact associated to the Plone user or None
1523
    """
1524
    if not user:
1525
        return None
1526
1527
    from senaite.core.catalog import CONTACT_CATALOG  # Avoid circular import
1528
    query = {"portal_type": contact_types, "getUsername": user.getId()}
1529
    brains = search(query, catalog=CONTACT_CATALOG)
1530
    if not brains:
1531
        return None
1532
1533
    if len(brains) > 1:
1534
        # Oops, the user has multiple contacts assigned, return None
1535
        contacts = map(lambda c: c.Title, brains)
1536
        err_msg = "User '{}' is bound to multiple Contacts '{}'"
1537
        err_msg = err_msg.format(user.getId(), ','.join(contacts))
1538
        logger.error(err_msg)
1539
        return None
1540
1541
    return get_object(brains[0])
1542
1543
1544
def get_user_client(user_or_contact):
1545
    """Returns the client of the contact of a Plone user
1546
1547
    If the user passed in has no contact or does not belong to any client,
1548
    returns None.
1549
1550
    :param: Plone user or contact
1551
    :returns: Client the contact of the Plone user belongs to
1552
    """
1553
    if not user_or_contact or ILabContact.providedBy(user_or_contact):
1554
        # Lab contacts cannot belong to a client
1555
        return None
1556
1557
    if not IContact.providedBy(user_or_contact):
1558
        contact = get_user_contact(user_or_contact, contact_types=['Contact'])
1559
        if IContact.providedBy(contact):
1560
            return get_user_client(contact)
1561
        return None
1562
1563
    client = get_parent(user_or_contact)
1564
    if client and IClient.providedBy(client):
1565
        return client
1566
1567
    return None
1568
1569
1570
def get_user_fullname(user_or_contact):
1571
    """Returns the fullname of the contact or Plone user.
1572
1573
    If the user has a linked contact, the fullname of the contact has priority
1574
    over the value of the fullname property from the user
1575
1576
    :param: Plone user or contact
1577
    :returns: Fullname of the contact or user
1578
    """
1579
    if IContact.providedBy(user_or_contact):
1580
        return user_or_contact.getFullname()
1581
1582
    user = get_user(user_or_contact)
1583
    if not user:
1584
        return ""
1585
1586
    # contact's fullname has priority over user's
1587
    contact = get_user_contact(user)
1588
    if not contact:
1589
        return user.getProperty("fullname")
1590
1591
    return contact.getFullname()
1592
1593
1594
def get_user_email(user_or_contact):
1595
    """Returns the email of the contact or Plone user.
1596
    If the user has a linked contact, the email of the contact has priority
1597
    over the value of the email property from the user
1598
    :param: Plone user or contact
1599
    :returns: Fullname of the contact or user
1600
    """
1601
    if IContact.providedBy(user_or_contact):
1602
        return user_or_contact.getEmailAddress()
1603
1604
    user = get_user(user_or_contact)
1605
    if not user:
1606
        return ""
1607
1608
    # contact's email has priority over user's
1609
    contact = get_user_contact(user)
1610
    if not contact:
1611
        return user.getProperty("email", default="")
1612
1613
    return contact.getEmailAddress()
1614
1615
1616
def get_current_client():
1617
    """Returns the current client the current logged in user belongs to, if any
1618
1619
    :returns: Client the current logged in user belongs to or None
1620
    """
1621
    return get_user_client(get_current_user())
1622
1623
1624
def get_cache_key(brain_or_object):
1625
    """Generate a cache key for a common brain or object
1626
1627
    :param brain_or_object: A single catalog brain or content object
1628
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
1629
    :returns: Cache Key
1630
    :rtype: str
1631
    """
1632
    key = [
1633
        get_portal_type(brain_or_object),
1634
        get_id(brain_or_object),
1635
        get_uid(brain_or_object),
1636
        # handle different domains gracefully
1637
        get_url(brain_or_object),
1638
        # Return the microsecond since the epoch in GMT
1639
        get_modification_date(brain_or_object).micros(),
1640
    ]
1641
    return "-".join(map(lambda x: str(x), key))
1642
1643
1644
def bika_cache_key_decorator(method, self, brain_or_object):
1645
    """Bika cache key decorator usable for
1646
1647
    :param brain_or_object: A single catalog brain or content object
1648
    :type brain_or_object: ATContentType/DexterityContentType/CatalogBrain
1649
    :returns: Cache Key
1650
    :rtype: str
1651
    """
1652
    if brain_or_object is None:
1653
        raise DontCache
1654
    return get_cache_key(brain_or_object)
1655
1656
1657
def normalize_id(string):
1658
    """Normalize the id
1659
1660
    :param string: A string to normalize
1661
    :type string: str
1662
    :returns: Normalized ID
1663
    :rtype: str
1664
    """
1665
    if not isinstance(string, six.string_types):
1666
        fail("Type of argument must be string, found '{}'"
1667
             .format(type(string)))
1668
    # get the id nomalizer utility
1669
    normalizer = getUtility(IIDNormalizer).normalize
1670
    return normalizer(string)
1671
1672
1673
def normalize_filename(string):
1674
    """Normalize the filename
1675
1676
    :param string: A string to normalize
1677
    :type string: str
1678
    :returns: Normalized ID
1679
    :rtype: str
1680
    """
1681
    if not isinstance(string, six.string_types):
1682
        fail("Type of argument must be string, found '{}'"
1683
             .format(type(string)))
1684
    # get the file nomalizer utility
1685
    normalizer = getUtility(IFileNameNormalizer).normalize
1686
    return normalizer(string)
1687
1688
1689
def is_uid(uid, validate=False):
1690
    """Checks if the passed in uid is a valid UID
1691
1692
    :param uid: The uid to check
1693
    :param validate: If False, checks if uid is a valid 23 alphanumeric uid. If
1694
    True, also verifies if a brain exists for the uid passed in
1695
    :type uid: string
1696
    :return: True if a valid uid
1697
    :rtype: bool
1698
    """
1699
    if not isinstance(uid, six.string_types):
1700
        return False
1701
    if uid == '0':
1702
        return True
1703
    if len(uid) != 32:
1704
        return False
1705
    if not UID_RX.match(uid):
1706
        return False
1707
    if not validate:
1708
        return True
1709
1710
    # Check if a brain for this uid exists
1711
    uc = get_tool('uid_catalog')
1712
    brains = uc(UID=uid)
1713
    if brains:
1714
        assert (len(brains) == 1)
1715
    return len(brains) > 0
1716
1717
1718
def is_date(date):
1719
    """Checks if the passed in value is a valid Zope's DateTime
1720
1721
    :param date: The date to check
1722
    :type date: DateTime
1723
    :return: True if a valid date
1724
    :rtype: bool
1725
    """
1726
    if not date:
1727
        return False
1728
    return isinstance(date, (DateTime, datetime))
1729
1730
1731
def to_date(value, default=None):
1732
    """Tries to convert the passed in value to Zope's DateTime
1733
1734
    :param value: The value to be converted to a valid DateTime
1735
    :type value: str, DateTime or datetime
1736
    :return: The DateTime representation of the value passed in or default
1737
    """
1738
1739
    # cannot use bika.lims.deprecated (circular dependencies)
1740
    import warnings
1741
    warnings.simplefilter("always", DeprecationWarning)
1742
    warn = "Deprecated: use senaite.core.api.dtime.to_DT instead"
1743
    warnings.warn(warn, category=DeprecationWarning, stacklevel=2)
1744
    warnings.simplefilter("default", DeprecationWarning)
1745
1746
    # prevent circular dependencies
1747
    from senaite.core.api.dtime import to_DT
1748
    date = to_DT(value)
1749
    if not date:
1750
        return to_DT(default)
1751
    return date
1752
1753
1754
def to_minutes(days=0, hours=0, minutes=0, seconds=0, milliseconds=0,
1755
               round_to_int=True):
1756
    """Returns the computed total number of minutes
1757
    """
1758
    total = float(days)*24*60 + float(hours)*60 + float(minutes) + \
1759
        float(seconds)/60 + float(milliseconds)/1000/60
1760
    return int(round(total)) if round_to_int else total
1761
1762
1763
def to_dhm_format(days=0, hours=0, minutes=0, seconds=0, milliseconds=0):
1764
    """Returns a representation of time in a string in xd yh zm format
1765
    """
1766
    minutes = to_minutes(days=days, hours=hours, minutes=minutes,
1767
                         seconds=seconds, milliseconds=milliseconds)
1768
    delta = timedelta(minutes=int(round(minutes)))
1769
    d = delta.days
1770
    h = delta.seconds // 3600
1771
    m = (delta.seconds // 60) % 60
1772
    m = m and "{}m ".format(str(m)) or ""
1773
    d = d and "{}d ".format(str(d)) or ""
1774
    if m and d:
1775
        h = "{}h ".format(str(h))
1776
    else:
1777
        h = h and "{}h ".format(str(h)) or ""
1778
    return "".join([d, h, m]).strip()
1779
1780
1781
def to_int(value, default=_marker):
1782
    """Tries to convert the value to int.
1783
    Truncates at the decimal point if the value is a float
1784
1785
    :param value: The value to be converted to an int
1786
    :return: The resulting int or default
1787
    """
1788
    if is_floatable(value):
1789
        value = to_float(value)
1790
    try:
1791
        return int(value)
1792
    except (TypeError, ValueError):
1793
        if default is None:
1794
            return default
1795
        if default is not _marker:
1796
            return to_int(default)
1797
        fail("Value %s cannot be converted to int" % repr(value))
1798
1799
1800
def is_floatable(value):
1801
    """Checks if the passed in value is a valid floatable number
1802
1803
    :param value: The value to be evaluated as a float number
1804
    :type value: str, float, int
1805
    :returns: True if is a valid float number
1806
    :rtype: bool"""
1807
    try:
1808
        float(value)
1809
        return True
1810
    except (TypeError, ValueError):
1811
        return False
1812
1813
1814
def to_float(value, default=_marker):
1815
    """Converts the passed in value to a float number
1816
1817
    :param value: The value to be converted to a floatable number
1818
    :type value: str, float, int
1819
    :returns: The float number representation of the passed in value
1820
    :rtype: float
1821
    """
1822
    if not is_floatable(value):
1823
        if default is not _marker:
1824
            return to_float(default)
1825
        fail("Value %s is not floatable" % repr(value))
1826
    return float(value)
1827
1828
1829
def float_to_string(value, default=_marker):
1830
    """Convert a float value to string without exponential notation
1831
1832
    This function preserves the whole fraction
1833
1834
    :param value: The float value to be converted to a string
1835
    :type value: str, float, int
1836
    :returns: String representation of the float w/o exponential notation
1837
    :rtype: str
1838
    """
1839
    if not is_floatable(value):
1840
        if default is not _marker:
1841
            return default
1842
        fail("Value %s is not floatable" % repr(value))
1843
1844
    # Leave floatable string values unchanged
1845
    if isinstance(value, six.string_types):
1846
        return value
1847
1848
    value = float(value)
1849
    str_value = str(value)
1850
1851
    if "." in str_value:
1852
        # might be something like 1.23e-26
1853
        front, back = str_value.split(".")
1854
    else:
1855
        # or 1e-07 for 0.0000001
1856
        back = str_value
1857
1858
    if "e-" in back:
1859
        fraction, zeros = back.split("e-")
1860
        # we want to cover the faction and the zeros
1861
        precision = len(fraction) + int(zeros)
1862
        template = "{:.%df}" % precision
1863
        str_value = template.format(value)
1864
    elif "e+" in back:
1865
        # positive numbers, e.g. 1e+16 don't need a fractional part
1866
        str_value = "{:.0f}".format(value)
1867
1868
    # cut off trailing zeros
1869
    if "." in str_value:
1870
        str_value = str_value.rstrip("0").rstrip(".")
1871
1872
    return str_value
1873
1874
1875
def to_searchable_text_metadata(value):
1876
    """Parse the given metadata value to searchable text
1877
1878
    :param value: The raw value of the metadata column
1879
    :returns: Searchable and translated unicode value or None
1880
    """
1881
    if not value:
1882
        return u""
1883
    if value is Missing.Value:
1884
        return u""
1885
    if is_uid(value):
1886
        return u""
1887
    if isinstance(value, (bool)):
1888
        return u""
1889
    if isinstance(value, (list, tuple)):
1890
        values = map(to_searchable_text_metadata, value)
1891
        values = filter(None, values)
1892
        return " ".join(values)
1893
    if isinstance(value, dict):
1894
        return to_searchable_text_metadata(value.values())
1895
    if is_date(value):
1896
        from senaite.core.api.dtime import date_to_string
1897
        return date_to_string(value, "%Y-%m-%d")
1898
    if is_at_content(value):
1899
        return to_searchable_text_metadata(get_title(value))
1900
    if not isinstance(value, six.string_types):
1901
        value = str(value)
1902
    return safe_unicode(value)
1903
1904
1905
def get_registry_record(name, default=None):
1906
    """Returns the value of a registry record
1907
1908
    :param name: [required] name of the registry record
1909
    :type name: str
1910
    :param default: The value returned if the record is not found
1911
    :type default: anything
1912
    :returns: value of the registry record
1913
    """
1914
    return ploneapi.portal.get_registry_record(name, default=default)
1915
1916
1917
def to_display_list(pairs, sort_by="key", allow_empty=True):
1918
    """Create a Plone DisplayList from list items
1919
1920
    :param pairs: list of key, value pairs
1921
    :param sort_by: Sort the items either by key or value
1922
    :param allow_empty: Allow to select an empty value
1923
    :returns: Plone DisplayList
1924
    """
1925
    dl = DisplayList()
1926
1927
    if isinstance(pairs, six.string_types):
1928
        pairs = [pairs, pairs]
1929
    for pair in pairs:
1930
        # pairs is a list of lists -> add each pair
1931
        if isinstance(pair, (tuple, list)):
1932
            dl.add(*pair)
1933
        # pairs is just a single pair -> add it and stop
1934
        if isinstance(pair, six.string_types):
1935
            dl.add(*pairs)
1936
            break
1937
1938
    # add the empty option
1939
    if allow_empty:
1940
        dl.add("", "")
1941
1942
    # sort by key/value
1943
    if sort_by == "key":
1944
        dl = dl.sortedByKey()
1945
    elif sort_by == "value":
1946
        dl = dl.sortedByValue()
1947
1948
    return dl
1949
1950
1951
def text_to_html(text, wrap="p", encoding="utf8"):
1952
    """Convert `\n` sequences in the text to HTML `\n`
1953
1954
    :param text: Plain text to convert
1955
    :param wrap: Toggle to wrap the text in a
1956
    :returns: HTML converted and encoded text
1957
    """
1958
    if not text:
1959
        return ""
1960
    # handle text internally as unicode
1961
    text = safe_unicode(text)
1962
    # replace newline characters with HTML entities
1963
    html = text.replace("\n", "<br/>")
1964
    if wrap:
1965
        html = u"<{tag}>{html}</{tag}>".format(
1966
            tag=wrap, html=html)
1967
    # return encoded html
1968
    return html.encode(encoding)
1969
1970
1971
def to_utf8(string, default=_marker):
1972
    """Encode string to UTF8
1973
1974
    :param string: String to be encoded to UTF8
1975
    :returns: UTF8 encoded string
1976
    """
1977
    if not isinstance(string, six.string_types):
1978
        if default is _marker:
1979
            fail("Expected string type, got '%s'" % type(string))
1980
        return default
1981
    return safe_unicode(string).encode("utf8")
1982
1983
1984
def is_temporary(obj):
1985
    """Returns whether the given object is temporary or not
1986
1987
    :param obj: the object to evaluate
1988
    :returns: True if the object is temporary
1989
    """
1990
    if ITemporaryObject.providedBy(obj):
1991
        return True
1992
1993
    obj_id = getattr(aq_base(obj), "id", None)
1994
    if obj_id is None or UID_RX.match(obj_id):
1995
        return True
1996
1997
    parent = aq_parent(aq_inner(obj))
1998
    if not parent:
1999
        return True
2000
2001
    parent_id = getattr(aq_base(parent), "id", None)
2002
    if parent_id is None or UID_RX.match(parent_id):
2003
        return True
2004
2005
    # Checks to see if we are created inside the portal_factory.
2006
    # This might also happen for DX types in senaite.databox!
2007
    meta_type = getattr(aq_base(parent), "meta_type", "")
2008
    if meta_type == "TempFolder":
2009
        return True
2010
2011
    return False
2012
2013
2014
def mark_temporary(brain_or_object):
2015
    """Mark the object as temporary
2016
    """
2017
    obj = get_object(brain_or_object)
2018
    alsoProvides(obj, ITemporaryObject)
2019
2020
2021
def unmark_temporary(brain_or_object):
2022
    """Unmark the object as temporary
2023
    """
2024
    obj = get_object(brain_or_object)
2025
    noLongerProvides(obj, ITemporaryObject)
2026
2027
2028
def is_string(thing):
2029
    """Checks if the passed in object is a string type
2030
2031
    :param thing: object to test
2032
    :returns: True if the object is a string
2033
    """
2034
    return isinstance(thing, six.string_types)
2035
2036
2037
def is_list(thing):
2038
    """Checks if the passed in object is a list type
2039
2040
    :param thing: object to test
2041
    :returns: True if the object is a list
2042
    """
2043
    return isinstance(thing, list)
2044
2045
2046
def is_list_iterable(thing):
2047
    """Checks if the passed in object can be iterated like a list
2048
2049
    :param thing: object to test
2050
    :returns: True if the object is a list, tuple or set
2051
    """
2052
    return isinstance(thing, (list, tuple, set))
2053
2054
2055
def parse_json(thing, default=""):
2056
    """Parse from JSON
2057
2058
    :param thing: thing to parse
2059
    :param default: value to return if cannot parse
2060
    :returns: the object representing the JSON or default
2061
    """
2062
    try:
2063
        return json.loads(thing)
2064
    except (TypeError, ValueError):
2065
        return default
2066
2067
2068
def to_list(value):
2069
    """Converts the value to a list
2070
2071
    :param value: the value to be represented as a list
2072
    :returns: a list that represents or contains the value
2073
    """
2074
    if is_string(value):
2075
        val = parse_json(value)
2076
        if isinstance(val, (list, tuple, set)):
2077
            value = val
2078
    if not isinstance(value, (list, tuple, set)):
2079
        value = [value]
2080
    return list(value)
2081
2082
2083
def validate(obj, invariants=True):
2084
    """Validates the full object
2085
2086
    :param obj: the object to validate the data against
2087
    :type obj: ATContentType/DexterityContentType
2088
    :returns: a dict with field names as keys and errors as values
2089
    """
2090
    if is_at_content(obj):
2091
        return obj.validate(data=True)
2092
2093
    if not is_dexterity_content(obj):
2094
        raise TypeError("%r is not supported" % type(obj))
2095
2096
    def is_string_field(field):
2097
        """Check if the field is a string field
2098
        """
2099
        return isinstance(field, (StringField)) or \
2100
            getattr(field, "_type", None) in [str]
2101
2102
    errors = {}
2103
2104
    # iterate through object fields and validate each
2105
    fields = get_fields(obj)
2106
2107
    for field_name, field in fields.items():
2108
        if field_name in SKIP_VALIDATION_FIELDS:
2109
            continue
2110
2111
        value = getattr(obj, field_name, None)
2112
2113
        if callable(value):
2114
            # Handle callable values, e.g. effective, expired etc.
2115
            value = value()
2116
        if isinstance(value, six.string_types):
2117
            value = safe_unicode(value)
2118
        if is_string_field(field):
2119
            # provide UTF8 encoded strings for stringfields, e.g. the ID field.
2120
            value = to_utf8(value)
2121
2122
        try:
2123
            field.validate(value)
2124
        except RequiredMissing:
2125
            errors[field_name] = "required field"
2126
        except WrongType:
2127
            # ignore wrong type errors if the field is not required and unset
2128
            if value is not None:
2129
                errors[field_name] = "wrong type"
2130
        except Invalid as ex:
2131
            errors[field_name] = translate(ex.message)
2132
2133
    # validate invariants from schema
2134
    sch = get_schema(obj)
2135
    try:
2136
        sch.validateInvariants(obj)
2137
    except Invalid as ex:
2138
        errors[sch.getName()] = translate(ex.message)
2139
2140
    # validate invariants from behaviors
2141
    for behavior_id in get_behaviors(obj):
2142
        behavior = lookup_behavior_registration(behavior_id)
2143
        try:
2144
            behavior.interface.validateInvariants(obj)
2145
        except Invalid as ex:
2146
            errors[behavior_id] = translate(ex.message)
2147
2148
    return errors
2149
2150
2151
def get_portal_types():
2152
    """Returns a list with the registered portal types
2153
2154
    :returns: List of portal type names
2155
    :rtype: list
2156
    """
2157
    types_tool = get_tool("portal_types")
2158
    return types_tool.listContentTypes()
2159
2160
2161
def is_valid_id(thing, container=None):
2162
    """Checks if is a valid ID candidate based on the following conditions:
2163
2164
      - Contains only letters, numbers, hyphens ('-'), or underscores ('_').
2165
      - Starts with a letter or a number.
2166
      - Ends with a letter or a number.
2167
      - Has a minimum length of 3 characters.
2168
      - Does not match reserved words (e.g., 'REQUEST') or portal type names.
2169
2170
    If a container is provided, it also verifies the container does not have
2171
    any attribute or function with same id.
2172
2173
    :param id: the id to validate
2174
    :type id: str
2175
    :returns: True if the id meets all the conditions, False otherwise.
2176
    """
2177
    id_rx = re.compile(r"^[a-z0-9][a-z0-9_\-]+[a-z0-9]$")
2178
    illegal = re.compile(r"^(aq_|manage|request).*")
2179
2180
    if not is_string(thing):
2181
        return False
2182
2183
    # convert to lower to simplify regex
2184
    lower = thing.lower()
2185
2186
    # check length and characters
2187
    if not id_rx.match(lower):
2188
        return False
2189
2190
    # check for reserved/illegal word
2191
    if illegal.match(lower):
2192
        return False
2193
2194
    # check for portal type names
2195
    portal_types = get_portal_types()
2196
    if thing in portal_types:
2197
        return False
2198
2199
    # check for container attributes and functions
2200
    if container and hasattr(container, thing):
2201
        return False
2202
2203
    return True
2204