Passed
Push — master ( 10ce0d...03dfc1 )
by Jordi
07:54 queued 03:58
created

bika.lims.idserver.search_by_prefix()   A

Complexity

Conditions 2

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 4
dl 0
loc 7
rs 10
c 0
b 0
f 0
cc 2
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-2019 by it's authors.
19
# Some rights reserved, see README and LICENSE.
20
21
import itertools
22
import re
23
24
import transaction
25
from bika.lims import api
26
from bika.lims import logger
27
from bika.lims.alphanumber import Alphanumber
28
from bika.lims.alphanumber import to_alpha
29
from bika.lims.browser.fields.uidreferencefield import \
30
    get_backreferences as get_backuidreferences
31
from bika.lims.interfaces import IAnalysisRequest
32
from bika.lims.interfaces import IAnalysisRequestPartition
33
from bika.lims.interfaces import IAnalysisRequestRetest
34
from bika.lims.interfaces import IAnalysisRequestSecondary
35
from bika.lims.interfaces import IIdServer
36
from bika.lims.numbergenerator import INumberGenerator
37
from DateTime import DateTime
38
from Products.ATContentTypes.utils import DT2dt
39
from zope.component import getAdapters
40
from zope.component import getUtility
41
42
AR_TYPES = [
43
    "AnalysisRequest",
44
    "AnalysisRequestRetest",
45
    "AnalysisRequestPartition",
46
    "AnalysisRequestSecondary",
47
]
48
49
50
def get_objects_in_sequence(brain_or_object, ctype, cref):
51
    """Return a list of items
52
    """
53
    obj = api.get_object(brain_or_object)
54
    if ctype == "backreference":
55
        return get_backreferences(obj, cref)
56
    if ctype == "contained":
57
        return get_contained_items(obj, cref)
58
    raise ValueError("Reference value is mandatory for sequence type counter")
59
60
61
def get_backreferences(obj, relationship):
62
    """Returns the backreferences
63
    """
64
    refs = get_backuidreferences(obj, relationship)
65
66
    # TODO remove after all ReferenceField get ported to UIDReferenceField
67
    # At this moment, there are still some content types that are using the
68
    # ReferenceField, so we need to fallback to traditional getBackReferences
69
    # for these cases.
70
    if not refs:
71
        refs = obj.getBackReferences(relationship)
72
73
    return refs
74
75
76
def get_contained_items(obj, spec):
77
    """Returns a list of (id, subobject) tuples of the current context.
78
    If 'spec' is specified, returns only objects whose meta_type match 'spec'
79
    """
80
    return obj.objectItems(spec)
81
82
83
def get_type_id(context, **kw):
84
    """Returns the type id for the context passed in
85
    """
86
    portal_type = kw.get("portal_type", None)
87
    if portal_type:
88
        return portal_type
89
90
    # Override by provided marker interface
91
    if IAnalysisRequestPartition.providedBy(context):
92
        return "AnalysisRequestPartition"
93
    elif IAnalysisRequestRetest.providedBy(context):
94
        return "AnalysisRequestRetest"
95
    elif IAnalysisRequestSecondary.providedBy(context):
96
        return "AnalysisRequestSecondary"
97
98
    return api.get_portal_type(context)
99
100
101
def get_suffix(id, regex="-[A-Z]{1}[0-9]{1,2}$"):
102
    """Get the suffix of the ID, e.g. '-R01' or '-P05'
103
104
    The current regex determines a pattern of a single uppercase character with
105
    at most 2 numbers following at the end of the ID as the suffix.
106
    """
107
    parts = re.findall(regex, id)
108
    if not parts:
109
        return ""
110
    return parts[0]
111
112
113
def strip_suffix(id):
114
    """Split off any suffix from ID
115
116
    This mimics the old behavior of the Sample ID.
117
    """
118
    suffix = get_suffix(id)
119
    if not suffix:
120
        return id
121
    return re.split(suffix, id)[0]
122
123
124
def get_retest_count(context, default=0):
125
    """Returns the number of retests of this AR
126
    """
127
    if not is_ar(context):
128
        return default
129
130
    invalidated = context.getInvalidated()
131
132
    count = 0
133
    while invalidated:
134
        count += 1
135
        invalidated = invalidated.getInvalidated()
136
137
    return count
138
139
140
def get_partition_count(context, default=0):
141
    """Returns the number of partitions of this AR
142
    """
143
    if not is_ar(context):
144
        return default
145
146
    parent = context.getParentAnalysisRequest()
147
148
    if not parent:
149
        return default
150
151
    return len(parent.getDescendants())
152
153
154
def get_secondary_count(context, default=0):
155
    """Returns the number of secondary ARs of this AR
156
    """
157
    if not is_ar(context):
158
        return default
159
160
    primary = context.getPrimaryAnalysisRequest()
161
162
    if not primary:
163
        return default
164
165
    return len(primary.getSecondaryAnalysisRequests())
166
167
168
def is_ar(context):
169
    """Checks if the context is an AR
170
    """
171
    return IAnalysisRequest.providedBy(context)
172
173
174
def get_config(context, **kw):
175
    """Fetch the config dict from the Bika Setup for the given portal_type
176
    """
177
    # get the ID formatting config
178
    config_map = api.get_bika_setup().getIDFormatting()
179
180
    # allow portal_type override
181
    portal_type = get_type_id(context, **kw)
182
183
    # check if we have a config for the given portal_type
184
    for config in config_map:
185
        if config['portal_type'].lower() == portal_type.lower():
186
            return config
187
188
    # return a default config
189
    default_config = {
190
        'form': '%s-{seq}' % portal_type.lower(),
191
        'sequence_type': 'generated',
192
        'prefix': '%s' % portal_type.lower(),
193
    }
194
    return default_config
195
196
197
def get_variables(context, **kw):
198
    """Prepares a dictionary of key->value pairs usable for ID formatting
199
    """
200
    # allow portal_type override
201
    portal_type = get_type_id(context, **kw)
202
203
    # The variables map hold the values that might get into the constructed id
204
    variables = {
205
        "context": context,
206
        "id": api.get_id(context),
207
        "portal_type": portal_type,
208
        "year": get_current_year(),
209
        "parent": api.get_parent(context),
210
        "seq": 0,
211
        "alpha": Alphanumber(0),
212
    }
213
214
    # Augment the variables map depending on the portal type
215
    if portal_type in AR_TYPES:
216
        now = DateTime()
217
        sampling_date = context.getSamplingDate()
218
        sampling_date = sampling_date and DT2dt(sampling_date) or DT2dt(now)
219
        date_sampled = context.getDateSampled()
220
        date_sampled = date_sampled and DT2dt(date_sampled) or DT2dt(now)
221
        test_count = 1
222
223
        variables.update({
224
            "clientId": context.getClientID(),
225
            "dateSampled": date_sampled,
226
            "samplingDate": sampling_date,
227
            "sampleType": context.getSampleType().getPrefix(),
228
            "test_count": test_count
229
        })
230
231
        # Partition
232
        if portal_type == "AnalysisRequestPartition":
233
            parent_ar = context.getParentAnalysisRequest()
234
            parent_ar_id = api.get_id(parent_ar)
235
            parent_base_id = strip_suffix(parent_ar_id)
236
            partition_count = get_partition_count(context)
237
            variables.update({
238
                "parent_analysisrequest": parent_ar,
239
                "parent_ar_id": parent_ar_id,
240
                "parent_base_id": parent_base_id,
241
                "partition_count": partition_count,
242
            })
243
244
        # Retest
245
        elif portal_type == "AnalysisRequestRetest":
246
            # Note: we use "parent" instead of "invalidated" for simplicity
247
            parent_ar = context.getInvalidated()
248
            parent_ar_id = api.get_id(parent_ar)
249
            parent_base_id = strip_suffix(parent_ar_id)
250
            # keep the full ID if the retracted AR is a partition
251
            if context.isPartition():
252
                parent_base_id = parent_ar_id
253
            retest_count = get_retest_count(context)
254
            test_count = test_count + retest_count
255
            variables.update({
256
                "parent_analysisrequest": parent_ar,
257
                "parent_ar_id": parent_ar_id,
258
                "parent_base_id": parent_base_id,
259
                "retest_count": retest_count,
260
                "test_count": test_count,
261
            })
262
263
        # Secondary
264
        elif portal_type == "AnalysisRequestSecondary":
265
            primary_ar = context.getPrimaryAnalysisRequest()
266
            primary_ar_id = api.get_id(primary_ar)
267
            parent_base_id = strip_suffix(primary_ar_id)
268
            secondary_count = get_secondary_count(context)
269
            variables.update({
270
                "parent_analysisrequest": primary_ar,
271
                "parent_ar_id": primary_ar_id,
272
                "parent_base_id": parent_base_id,
273
                "secondary_count": secondary_count,
274
            })
275
276
    elif portal_type == "ARReport":
277
        variables.update({
278
            "clientId": context.aq_parent.getClientID(),
279
        })
280
281
    return variables
282
283
284
def split(string, separator="-"):
285
    """ split a string on the given separator
286
    """
287
    if not isinstance(string, basestring):
288
        return []
289
    return string.split(separator)
290
291
292
def to_int(thing, default=0):
293
    """Convert a thing to an integer
294
    """
295
    try:
296
        return int(thing)
297
    except (TypeError, ValueError):
298
        return default
299
300
301
def slice(string, separator="-", start=None, end=None):
302
    """Slice out a segment of a string, which is splitted on both the wildcards
303
    and the separator passed in, if any
304
    """
305
    # split by wildcards/keywords first
306
    # AR-{sampleType}-{parentId}{alpha:3a2d}
307
    segments = filter(None, re.split('(\{.+?\})', string))
308
    # ['AR-', '{sampleType}', '-', '{parentId}', '{alpha:3a2d}']
309
    if separator:
310
        # Keep track of singleton separators as empties
311
        # We need to do this to prevent duplicates later, when splitting
312
        segments = map(lambda seg: seg!=separator and seg or "", segments)
313
        # ['AR-', '{sampleType}', '', '{parentId}', '{alpha:3a2d}']
314
        # Split each segment at the given separator
315
        segments = map(lambda seg: split(seg, separator), segments)
316
        # [['AR', ''], ['{sampleType}'], [''], ['{parentId}'], ['{alpha:3a2d}']]
317
        # Flatten the list
318
        segments = list(itertools.chain.from_iterable(segments))
319
        # ['AR', '', '{sampleType}', '', '{parentId}', '{alpha:3a2d}']
320
        # And replace empties with separator
321
        segments = map(lambda seg: seg!="" and seg or separator, segments)
322
        # ['AR', '-', '{sampleType}', '-', '{parentId}', '{alpha:3a2d}']
323
324
    # Get the start and end positions from the segments without separator
325
    cleaned_segments = filter(lambda seg: seg!=separator, segments)
326
    start_pos = to_int(start, 0)
327
    # Note "end" is not a position, but the number of elements to join!
328
    end_pos = to_int(end, len(cleaned_segments) - start_pos) + start_pos - 1
329
330
    # Map the positions against the segments with separator
331
    start = segments.index(cleaned_segments[start_pos])
332
    end = segments.index(cleaned_segments[end_pos]) + 1
333
334
    # Return all segments joined
335
    sliced_parts = segments[start:end]
336
    return "".join(sliced_parts)
337
338
339
def get_current_year():
340
    """Returns the current year as a two digit string
341
    """
342
    return DateTime().strftime("%Y")[2:]
343
344
345
def make_storage_key(portal_type, prefix=None):
346
    """Make a storage (dict-) key for the number generator
347
    """
348
    key = portal_type.lower()
349
    if prefix:
350
        key = "{}-{}".format(key, prefix)
351
    return key
352
353
354
def get_seq_number_from_id(id, id_template, prefix, **kw):
355
    """Return the sequence number of the given ID
356
    """
357
    separator = kw.get("separator", "-")
358
    postfix = id.replace(prefix, "").strip(separator)
359
    postfix_segments = postfix.split(separator)
360
    seq_number = 0
361
    possible_seq_nums = filter(lambda n: n.isalnum(), postfix_segments)
362
    if possible_seq_nums:
363
        seq_number = possible_seq_nums[-1]
364
365
    # Check if this id has to be expressed as an alphanumeric number
366
    seq_number = get_alpha_or_number(seq_number, id_template)
367
    seq_number = to_int(seq_number)
368
    return seq_number
369
370
371
def get_alpha_or_number(number, template):
372
    """Returns an Alphanumber that represents the number passed in, expressed
373
    as defined in the template. Otherwise, returns the number
374
    """
375
    match = re.match(r".*\{alpha:(\d+a\d+d)\}$", template.strip())
376
    if match and match.groups():
377
        format = match.groups()[0]
378
        return to_alpha(number, format)
379
    return number
380
381
382
def get_counted_number(context, config, variables, **kw):
383
    """Compute the number for the sequence type "Counter"
384
    """
385
    # This "context" is defined by the user in the Setup and can be actually
386
    # anything. However, we assume it is something like "sample" or similar
387
    ctx = config.get("context")
388
389
    # get object behind the context name (falls back to the current context)
390
    obj = variables.get(ctx, context)
391
392
    # get the counter type, which is either "backreference" or "contained"
393
    counter_type = config.get("counter_type")
394
395
    # the counter reference is either the "relationship" for
396
    # "backreference" or the meta type for contained objects
397
    counter_reference = config.get("counter_reference")
398
399
    # This should be a list of existing items, including the current context
400
    # object
401
    seq_items = get_objects_in_sequence(obj, counter_type, counter_reference)
402
403
    number = len(seq_items)
404
    return number
405
406
407
def get_generated_number(context, config, variables, **kw):
408
    """Generate a new persistent number with the number generator for the
409
    sequence type "Generated"
410
    """
411
    # separator where to split the ID
412
    separator = kw.get('separator', '-')
413
414
    # allow portal_type override
415
    portal_type = get_type_id(context, **kw)
416
417
    # The ID format for string interpolation, e.g. WS-{seq:03d}
418
    id_template = config.get("form", "")
419
420
    # The split length defines where the key is splitted from the value
421
    split_length = config.get("split_length", 1)
422
423
    # The prefix template is the static part of the ID
424
    prefix_template = slice(id_template, separator=separator, end=split_length)
425
426
    # get the number generator
427
    number_generator = getUtility(INumberGenerator)
428
429
    # generate the key for the number generator storage
430
    prefix = prefix_template.format(**variables)
431
432
    # normalize out any unicode characters like Ö, É, etc. from the prefix
433
    prefix = api.normalize_filename(prefix)
434
435
    # The key used for the storage
436
    key = make_storage_key(portal_type, prefix)
437
438
    if not kw.get("dry_run", False):
439
        # Generate a new number
440
        # NOTE Even when the number exceeds the given ID sequence format,
441
        #      it will overflow gracefully, e.g.
442
        #      >>> {sampleId}-R{seq:03d}'.format(sampleId="Water", seq=999999)
443
        #      'Water-R999999‘
444
        number = number_generator.generate_number(key=key)
445
    else:
446
        # => This allows us to "preview" the next generated ID in the UI
447
        # TODO Show the user the next generated number somewhere in the UI
448
        number = number_generator.get(key, 1)
449
450
    # Return an int or Alphanumber
451
    return get_alpha_or_number(number, id_template)
452
453
454
def generateUniqueId(context, **kw):
455
    """ Generate pretty content IDs.
456
    """
457
458
    # get the config for this portal type from the system setup
459
    config = get_config(context, **kw)
460
461
    # get the variables map for later string interpolation
462
    variables = get_variables(context, **kw)
463
464
    # The new generate sequence number
465
    number = 0
466
467
    # get the sequence type from the global config
468
    sequence_type = config.get("sequence_type", "generated")
469
470
    # Sequence Type is "Counter", so we use the length of the backreferences or
471
    # contained objects of the evaluated "context" defined in the config
472
    if sequence_type in ["counter"]:
473
        number = get_counted_number(context, config, variables, **kw)
474
475
    # Sequence Type is "Generated", so the ID is constructed according to the
476
    # configured split length
477
    if sequence_type in ["generated"]:
478
        number = get_generated_number(context, config, variables, **kw)
479
480
    # store the new sequence number to the variables map for str interpolation
481
    if isinstance(number, Alphanumber):
482
        variables["alpha"] = number
483
    variables["seq"] = to_int(number)
484
485
    # The ID formatting template from user config, e.g. {sampleId}-R{seq:02d}
486
    id_template = config.get("form", "")
487
488
    # Interpolate the ID template
489
    try:
490
        new_id = id_template.format(**variables)
491
    except KeyError, e:
492
        logger.error('KeyError: {} not in id_template {}'.format(
493
            e, id_template))
494
        raise
495
    normalized_id = api.normalize_filename(new_id)
496
    logger.info("generateUniqueId: {}".format(normalized_id))
497
498
    return normalized_id
499
500
501
def renameAfterCreation(obj):
502
    """Rename the content after it was created/added
503
    """
504
    # Can't rename without a subtransaction commit when using portal_factory
505
    transaction.savepoint(optimistic=True)
506
507
    # The id returned should be normalized already
508
    new_id = None
509
510
    # Checking if an adapter exists for this content type. If yes, we will
511
    # get new_id from adapter.
512
    for name, adapter in getAdapters((obj, ), IIdServer):
513
        if new_id:
514
            logger.warn(('More than one ID Generator Adapter found for'
515
                         'content type -> %s') % obj.portal_type)
516
        new_id = adapter.generate_id(obj.portal_type)
517
    if not new_id:
518
        new_id = generateUniqueId(obj)
519
520
    # rename the object to the new id
521
    parent = api.get_parent(obj)
522
    parent.manage_renameObject(obj.id, new_id)
523
524
    return new_id
525