Passed
Push — master ( 96f67f...194dfd )
by Ramon
05:02
created

bika.lims.idserver   C

Complexity

Total Complexity 53

Size/Duplication

Total Lines 449
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 53
eloc 227
dl 0
loc 449
rs 6.96
c 0
b 0
f 0

19 Functions

Rating   Name   Duplication   Size   Complexity  
A get_config() 0 21 3
A idserver_generate_id() 0 28 3
A get_contained_items() 0 5 1
A get_objects_in_sequence() 0 9 3
A get_backreferences() 0 13 2
A make_storage_key() 0 7 2
A search_by_prefix() 0 7 2
B generateUniqueId() 0 45 5
A slice() 0 15 1
B get_generated_number() 0 58 5
B get_variables() 0 70 7
A split() 0 6 2
A get_seq_number_from_id() 0 15 3
A get_ids_with_prefix() 0 6 1
A to_int() 0 7 2
B renameAfterCreation() 0 35 6
A get_current_year() 0 4 1
A get_counted_number() 0 23 1
A get_alpha_or_number() 0 9 3

How to fix   Complexity   

Complexity

Complex classes like bika.lims.idserver often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
# -*- coding: utf-8 -*-
2
#
3
# This file is part of SENAITE.CORE
4
#
5
# Copyright 2018 by it's authors.
6
# Some rights reserved. See LICENSE.rst, CONTRIBUTORS.rst.
7
8
import re
9
import urllib
10
11
import transaction
12
import zLOG
13
from DateTime import DateTime
14
from Products.ATContentTypes.utils import DT2dt
15
from bika.lims import api
16
from bika.lims import bikaMessageFactory as _
17
from bika.lims import logger
18
from bika.lims.alphanumber import to_alpha, Alphanumber
19
from bika.lims.browser.fields.uidreferencefield \
20
    import get_backreferences as get_backuidreferences
21
from bika.lims.interfaces import IIdServer
22
from bika.lims.numbergenerator import INumberGenerator
23
from zope.component import getAdapters
24
from zope.component import getUtility
25
26
27
class IDServerUnavailable(Exception):
28
    pass
29
30
31
def idserver_generate_id(context, prefix, batch_size=None):
32
    """ Generate a new id using external ID server.
33
    """
34
    plone = context.portal_url.getPortalObject()
35
    url = api.get_bika_setup().getIDServerURL()
36
37
    try:
38
        if batch_size:
39
            # GET
40
            f = urllib.urlopen('%s/%s/%s?%s' % (
41
                url,
42
                plone.getId(),
43
                prefix,
44
                urllib.urlencode({'batch_size': batch_size}))
45
            )
46
        else:
47
            f = urllib.urlopen('%s/%s/%s' % (url, plone.getId(), prefix))
48
        new_id = f.read()
49
        f.close()
50
    except:
51
        from sys import exc_info
52
        info = exc_info()
53
        msg = 'generate_id raised exception: {}, {} \n ID server URL: {}'
54
        msg = msg.format(info[0], info[1], url)
55
        zLOG.LOG('INFO', 0, '', msg)
56
        raise IDServerUnavailable(_('ID Server unavailable'))
57
58
    return new_id
59
60
61
def get_objects_in_sequence(brain_or_object, ctype, cref):
62
    """Return a list of items
63
    """
64
    obj = api.get_object(brain_or_object)
65
    if ctype == "backreference":
66
        return get_backreferences(obj, cref)
67
    if ctype == "contained":
68
        return get_contained_items(obj, cref)
69
    raise ValueError("Reference value is mandatory for sequence type counter")
70
71
72
def get_backreferences(obj, relationship):
73
    """Returns the backreferences
74
    """
75
    refs = get_backuidreferences(obj, relationship)
76
77
    # TODO remove after all ReferenceField get ported to UIDReferenceField
78
    # At this moment, there are still some content types that are using the
79
    # ReferenceField, so we need to fallback to traditional getBackReferences
80
    # for these cases.
81
    if not refs:
82
        refs = obj.getBackReferences(relationship)
83
84
    return refs
85
86
def get_contained_items(obj, spec):
87
    """Returns a list of (id, subobject) tuples of the current context.
88
    If 'spec' is specified, returns only objects whose meta_type match 'spec'
89
    """
90
    return obj.objectItems(spec)
91
92
93
def get_config(context, **kw):
94
    """Fetch the config dict from the Bika Setup for the given portal_type
95
    """
96
    # get the ID formatting config
97
    config_map = api.get_bika_setup().getIDFormatting()
98
99
    # allow portal_type override
100
    portal_type = kw.get("portal_type") or api.get_portal_type(context)
101
102
    # check if we have a config for the given portal_type
103
    for config in config_map:
104
        if config['portal_type'].lower() == portal_type.lower():
105
            return config
106
107
    # return a default config
108
    default_config = {
109
        'form': '%s-{seq}' % portal_type.lower(),
110
        'sequence_type': 'generated',
111
        'prefix': '%s' % portal_type.lower(),
112
    }
113
    return default_config
114
115
116
def get_variables(context, **kw):
117
    """Prepares a dictionary of key->value pairs usable for ID formatting
118
    """
119
120
    # allow portal_type override
121
    portal_type = kw.get("portal_type") or api.get_portal_type(context)
122
123
    # The variables map hold the values that might get into the constructed id
124
    variables = {
125
        'context': context,
126
        'id': api.get_id(context),
127
        'portal_type': portal_type,
128
        'year': get_current_year(),
129
        'parent': api.get_parent(context),
130
        'seq': 0,
131
        'alpha': Alphanumber(0),
132
    }
133
134
    # Augment the variables map depending on the portal type
135
    if portal_type == "AnalysisRequest":
136
        variables.update({
137
            'sampleId': context.getSample().getId(),
138
            'sample': context.getSample(),
139
        })
140
141
    elif portal_type == "SamplePartition":
142
        variables.update({
143
            'sampleId': context.aq_parent.getId(),
144
            'sample': context.aq_parent,
145
        })
146
147
    elif portal_type == "Sample":
148
        # get the prefix of the assigned sample type
149
        sample_id = context.getId()
150
        sample_type = context.getSampleType()
151
        sampletype_prefix = sample_type.getPrefix()
152
153
        date_now = DateTime()
154
        sampling_date = context.getSamplingDate()
155
        date_sampled = context.getDateSampled()
156
157
        # Try to get the date sampled and sampling date
158
        if sampling_date:
159
            samplingDate = DT2dt(sampling_date)
160
        else:
161
            # No Sample Date?
162
            logger.error("Sample {} has no sample date set".format(sample_id))
163
            # fall back to current date
164
            samplingDate = DT2dt(date_now)
165
166
        if date_sampled:
167
            dateSampled = DT2dt(date_sampled)
168
        else:
169
            # No Sample Date?
170
            logger.error("Sample {} has no sample date set".format(sample_id))
171
            dateSampled = DT2dt(date_now)
172
173
        variables.update({
174
            'clientId': context.aq_parent.getClientID(),
175
            'dateSampled': dateSampled,
176
            'samplingDate': samplingDate,
177
            'sampleType': sampletype_prefix,
178
        })
179
180
    elif portal_type == "ARReport":
181
        variables.update({
182
            'clientId': context.aq_parent.getClientID(),
183
        })
184
185
    return variables
186
187
188
def split(string, separator="-"):
189
    """ split a string on the given separator
190
    """
191
    if not isinstance(string, basestring):
192
        return []
193
    return string.split(separator)
194
195
196
def to_int(thing, default=0):
197
    """Convert a thing to an integer
198
    """
199
    try:
200
        return int(thing)
201
    except (TypeError, ValueError):
202
        return default
203
204
205
def slice(string, separator="-", start=None, end=None):
206
    """Slice out a segment of a string, which is splitted on separator.
207
    """
208
209
    # split the given string at the given separator
210
    segments = split(string, separator)
211
212
    # get the start and endposition for slicing
213
    length = len(segments)
214
    start = to_int(start)
215
    end = to_int(end, length)
216
217
    # return the separator joined sliced segments
218
    sliced_parts = segments[start:end]
219
    return separator.join(sliced_parts)
220
221
222
def get_current_year():
223
    """Returns the current year as a two digit string
224
    """
225
    return DateTime().strftime("%Y")[2:]
226
227
228
def search_by_prefix(portal_type, prefix):
229
    """Returns brains which share the same portal_type and ID prefix
230
    """
231
    catalog = api.get_tool("uid_catalog")
232
    brains = catalog({"portal_type": portal_type})
233
    # Filter brains with the same ID prefix
234
    return filter(lambda brain: api.get_id(brain).startswith(prefix), brains)
235
236
237
def get_ids_with_prefix(portal_type, prefix):
238
    """Return a list of ids sharing the same portal type and prefix
239
    """
240
    brains = search_by_prefix(portal_type, prefix)
241
    ids = map(api.get_id, brains)
242
    return ids
243
244
245
def make_storage_key(portal_type, prefix=None):
246
    """Make a storage (dict-) key for the number generator
247
    """
248
    key = portal_type.lower()
249
    if prefix:
250
        key = "{}-{}".format(key, prefix)
251
    return key
252
253
254
def get_seq_number_from_id(id, id_template, prefix, **kw):
255
    """Return the sequence number of the given ID
256
    """
257
    separator = kw.get("separator", "-")
258
    postfix = id.replace(prefix, "").strip(separator)
259
    postfix_segments = postfix.split(separator)
260
    seq_number = 0
261
    possible_seq_nums = filter(lambda n: n.isalnum(), postfix_segments)
262
    if possible_seq_nums:
263
        seq_number = possible_seq_nums[-1]
264
265
    # Check if this id has to be expressed as an alphanumeric number
266
    seq_number = get_alpha_or_number(seq_number, id_template)
267
    seq_number = to_int(seq_number)
268
    return seq_number
269
270
271
def get_alpha_or_number(number, template):
272
    """Returns an Alphanumber that represents the number passed in, expressed
273
    as defined in the template. Otherwise, returns the number
274
    """
275
    match = re.match(r".*\{alpha:(\d+a\d+d)\}$", template.strip())
276
    if match and match.groups():
277
        format = match.groups()[0]
278
        return to_alpha(number, format)
279
    return number
280
281
282
def get_counted_number(context, config, variables, **kw):
283
    """Compute the number for the sequence type "Counter"
284
    """
285
    # This "context" is defined by the user in Bika Setup and can be actually
286
    # anything. However, we assume it is something like "sample" or similar
287
    ctx = config.get("context")
288
289
    # get object behind the context name (falls back to the current context)
290
    obj = variables.get(ctx, context)
291
292
    # get the counter type, which is either "backreference" or "contained"
293
    counter_type = config.get("counter_type")
294
295
    # the counter reference is either the "relationship" for
296
    # "backreference" or the meta type for contained objects
297
    counter_reference = config.get("counter_reference")
298
299
    # This should be a list of existing items, including the current context
300
    # object
301
    seq_items = get_objects_in_sequence(obj, counter_type, counter_reference)
302
303
    number = len(seq_items)
304
    return number
305
306
307
def get_generated_number(context, config, variables, **kw):
308
    """Generate a new persistent number with the number generator for the
309
    sequence type "Generated"
310
    """
311
312
    # separator where to split the ID
313
    separator = kw.get('separator', '-')
314
315
    # allow portal_type override
316
    portal_type = kw.get("portal_type") or api.get_portal_type(context)
317
318
    # The ID format for string interpolation, e.g. WS-{seq:03d}
319
    id_template = config.get("form", "")
320
321
    # The split length defines where the variable part of the ID template begins
322
    split_length = config.get("split_length", 1)
323
324
    # The prefix tempalte is the static part of the ID
325
    prefix_template = slice(id_template, separator=separator, end=split_length)
326
327
    # get the number generator
328
    number_generator = getUtility(INumberGenerator)
329
330
    # generate the key for the number generator storage
331
    prefix = prefix_template.format(**variables)
332
333
    # normalize out any unicode characters like Ö, É, etc. from the prefix
334
    prefix = api.normalize_filename(prefix)
335
336
    # The key used for the storage
337
    key = make_storage_key(portal_type, prefix)
338
339
    # Handle flushed storage
340
    if key not in number_generator:
341
        max_num = 0
342
        existing = get_ids_with_prefix(portal_type, prefix)
343
        numbers = map(lambda id: get_seq_number_from_id(id, id_template, prefix), existing)
344
        # figure out the highest number in the sequence
345
        if numbers:
346
            max_num = max(numbers)
347
        # set the number generator
348
        logger.info("*** SEEDING Prefix '{}' to {}".format(prefix, max_num))
349
        number_generator.set_number(key, max_num)
350
351
    if not kw.get("dry_run", False):
352
        # Generate a new number
353
        # NOTE Even when the number exceeds the given ID sequence format,
354
        #      it will overflow gracefully, e.g.
355
        #      >>> {sampleId}-R{seq:03d}'.format(sampleId="Water", seq=999999)
356
        #      'Water-R999999‘
357
        number = number_generator.generate_number(key=key)
358
    else:
359
        # => This allows us to "preview" the next generated ID in the UI
360
        # TODO Show the user the next generated number somewhere in the UI
361
        number = number_generator.get(key, 1)
362
363
    # Return an int or Alphanumber
364
    return get_alpha_or_number(number, id_template)
365
366
367
def generateUniqueId(context, **kw):
368
    """ Generate pretty content IDs.
369
    """
370
371
    # get the config for this portal type from the system setup
372
    config = get_config(context, **kw)
373
374
    # get the variables map for later string interpolation
375
    variables = get_variables(context, **kw)
376
377
    # The new generate sequence number
378
    number = 0
379
380
    # get the sequence type from the global config
381
    sequence_type = config.get("sequence_type", "generated")
382
383
    # Sequence Type is "Counter", so we use the length of the backreferences or
384
    # contained objects of the evaluated "context" defined in the config
385
    if sequence_type == 'counter':
386
        number = get_counted_number(context, config, variables, **kw)
387
388
    # Sequence Type is "Generated", so the ID is constructed according to the
389
    # configured split length
390
    if sequence_type == 'generated':
391
        number = get_generated_number(context, config, variables, **kw)
392
393
    # store the new sequence number to the variables map for str interpolation
394
    if isinstance(number, Alphanumber):
395
        variables["alpha"] = number
396
    variables["seq"] = int(number)
397
398
    # The ID formatting template from user config, e.g. {sampleId}-R{seq:02d}
399
    id_template = config.get("form", "")
400
401
    # Interpolate the ID template
402
    try:
403
        new_id = id_template.format(**variables)
404
    except KeyError, e:
405
        logger.error('KeyError: {} not in id_template {}'.format(
406
            e, id_template))
407
        raise 
408
    normalized_id = api.normalize_filename(new_id)
409
    logger.info("generateUniqueId: {}".format(normalized_id))
410
411
    return normalized_id
412
413
414
def renameAfterCreation(obj):
415
    """Rename the content after it was created/added
416
    """
417
    # Check if the _bika_id was already set
418
    bika_id = getattr(obj, "_bika_id", None)
419
    if bika_id is not None:
420
        return bika_id
421
    # Can't rename without a subtransaction commit when using portal_factory
422
    transaction.savepoint(optimistic=True)
423
    # The id returned should be normalized already
424
    new_id = None
425
    # Checking if an adapter exists for this content type. If yes, we will
426
    # get new_id from adapter.
427
    for name, adapter in getAdapters((obj, ), IIdServer):
428
        if new_id:
429
            logger.warn(('More than one ID Generator Adapter found for'
430
                         'content type -> %s') % obj.portal_type)
431
        new_id = adapter.generate_id(obj.portal_type)
432
    if not new_id:
433
        new_id = generateUniqueId(obj)
434
435
    # TODO: This is a naive check just in current folder
436
    # -> this should check globally for duplicate objects with same prefix
437
    # N.B. a check like `search_by_prefix` each time would probably slow things
438
    # down too much!
439
    # -> A solution could be to store all IDs with a certain prefix in a storage
440
    parent = api.get_parent(obj)
441
    if new_id in parent.objectIds():
442
        # XXX We could do the check in a `while` loop and generate a new one.
443
        raise KeyError("The ID {} is already taken in the path {}".format(
444
            new_id, api.get_path(parent)))
445
    # rename the object to the new id
446
    parent.manage_renameObject(obj.id, new_id)
447
448
    return new_id
449