diff --git a/CHANGES.rst b/CHANGES.rst index 8f84a1335d..0482db5a45 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,6 +6,7 @@ Changelog **Added** +- #386 PR-2297 Added seeding function to IDServer - #372 Added build system to project root - #345 'SearchableText' field and adapter in Batches - #344 PR-2294 Allow year in any portal type's ID format string @@ -39,6 +40,9 @@ Changelog **Fixed** +- #386 PR-2313 UniqueFieldValidator: Encode value to utf-8 before passing it to the catalog +- #386 PR-2312 IDServer: Fixed default split length value +- #386 PR-2311 Fix ID Server to handle a flushed storage or existing IDs with the same prefix - #385 PR-2309 Some objects were missed in instrument listing views - #384 PR-2306 Do not use localized dates for control chart as it breaks the controlchart.js datetime parser - #382 PR-2305 TypeError in Analysis Specification category expansion diff --git a/bika/lims/browser/configure.zcml b/bika/lims/browser/configure.zcml index 4bd8a2507d..e8e70d2766 100644 --- a/bika/lims/browser/configure.zcml +++ b/bika/lims/browser/configure.zcml @@ -44,6 +44,7 @@ + diff --git a/bika/lims/browser/idserver/__init__.py b/bika/lims/browser/idserver/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/bika/lims/browser/idserver/configure.zcml b/bika/lims/browser/idserver/configure.zcml new file mode 100644 index 0000000000..1828093eb8 --- /dev/null +++ b/bika/lims/browser/idserver/configure.zcml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/bika/lims/browser/idserver/view.py b/bika/lims/browser/idserver/view.py new file mode 100644 index 0000000000..245ebf9794 --- /dev/null +++ b/bika/lims/browser/idserver/view.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- + +from bika.lims.browser import BrowserView +from bika.lims.numbergenerator import INumberGenerator +from zope.component import getUtility + + +class IDServerView(BrowserView): + """ This browser view is to house ID Server related functions + """ + + def seed(self): + """ Reset the number from which the next generated sequence start. + If you seed at 100, next seed will be 101 + """ + form = self.request.form + prefix = form.get('prefix', None) + if prefix is None: + return 'No prefix provided' + seed = form.get('seed', None) + if seed is None: + return 'No seed provided' + if not seed.isdigit(): + return 'Seed must be a digit' + seed = int(seed) + if seed < 0: + return 'Seed cannot be negative' + + number_generator = getUtility(INumberGenerator) + new_seq = number_generator.set_number(key=prefix, value=seed) + return 'IDServerView: "%s" seeded to %s' % (prefix, new_seq) + + def flush(self): + """ Flush the storage + """ + number_generator = getUtility(INumberGenerator) + number_generator.flush() + return "IDServerView: Number storage flushed!" diff --git a/bika/lims/configure.zcml b/bika/lims/configure.zcml index 52355ea39e..9f35fa24df 100644 --- a/bika/lims/configure.zcml +++ b/bika/lims/configure.zcml @@ -94,19 +94,11 @@ /> - + - - - diff --git a/bika/lims/content/bikasetup.py b/bika/lims/content/bikasetup.py index d831fd2058..bc69c41419 100644 --- a/bika/lims/content/bikasetup.py +++ b/bika/lims/content/bikasetup.py @@ -805,25 +805,49 @@ def getCounterTypes(self, instance=None): widget=RecordsWidget( label=_("Formatting Configuration"), allowDelete=True, - description=_("""The ID Server in Bika LIMS provides IDs for content items base of the given format specification. -The format string is constructed in the same way as a python format() method -based predefined variables per content type. The only variable available to all -type is 'seq'. Currently, 'seq' can be constructed either using number generator -or a counter of existing items. For generated IDs, one can specify the point at -which the format string will be split to create the generator key. For counter -IDs, one must specify the context and the type of counter which is either the -number of backreferences or the number of contained objects. -Configuration Settings: -* format: - - a python format string constructed from predefined variables like sampleId, client, sampleType. - - special variable 'seq' must be positioned last in the format string -* sequence type: [generated|counter] -* context: if type counter, provides context the counting function -* counter type: [backreference|contained] -* counter reference: a parameter to the counting function -* prefix: default prefix if none provided in format string -* split length: the number of parts to be included in the prefix -""") + description=_( + "

The Bika LIMS ID Server provides unique sequential IDs " + "for objects such as Samples and Worksheets etc, based on a " + "format specified for each content type.

" + "

The format is constructed similarly to the Python format" + " syntax, using predefined variables per content type, and" + " advancing the IDs through a sequence number, 'seq' and its" + " padding as a number of digits, e.g. '03d' for a sequence of" + " IDs from 001 to 999.

" + "

Alphanumeric prefixes for IDs are included as is in the" + " formats, e.g. WS for Worksheet in WS-{seq:03d} produces" + " sequential Worksheet IDs: WS-001, WS-002, WS-003 etc.

" + "

Variables that can be used include:" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "
Content TypeVariables
Client{client}
Year{year}
Sample ID{sampleId}
Sample Type{sampleType}
Sampling Date{samplingDate}
Date Sampled{dateSampled}
" + "

" + "

Configuration Settings:" + "

    " + "
  • format:" + "
    • a python format string constructed from predefined" + " variables like sampleId, client, sampleType.
    • " + "
    • special variable 'seq' must be positioned last in the" + "format string
  • " + "
  • sequence type: [generated|counter]
  • " + "
  • context: if type counter, provides context the counting" + " function
  • " + "
  • counter type: [backreference|contained]
  • " + "
  • counter reference: a parameter to the counting" + " function
  • " + "
  • prefix: default prefix if none provided in format" + " string
  • " + "
  • split length: the number of parts to be included in the" + " prefix
  • " + "

") ) ), BooleanField( diff --git a/bika/lims/docs/ContactUser.rst b/bika/lims/docs/ContactUser.rst new file mode 100644 index 0000000000..e3c3039bba --- /dev/null +++ b/bika/lims/docs/ContactUser.rst @@ -0,0 +1,249 @@ +================================== +Clients, Contacts and linked Users +================================== + +Clients are the customers of the lab. A client represents another company, which +has one or more natural persons as contacts. + +Each contact can be linked to a Plone system user. The linking process adds the +linked user to the "Clients" group, which has the "Customer" role. + +Furthermore, the user gets the local "Owner" role for the owning client object. + + +Test Setup +========== + + >>> import transaction + >>> from plone import api as ploneapi + >>> from zope.lifecycleevent import modified + >>> from AccessControl.PermissionRole import rolesForPermissionOn + >>> from plone.app.testing import setRoles + >>> from plone.app.testing import TEST_USER_ID + >>> from plone.app.testing import TEST_USER_PASSWORD + + >>> portal = self.getPortal() + >>> portal_url = portal.absolute_url() + >>> bika_setup = portal.bika_setup + >>> bika_setup_url = portal_url + "/bika_setup" + >>> browser = self.getBrowser() + >>> setRoles(portal, TEST_USER_ID, ['LabManager', 'Manager', 'Owner']) + + >>> def start_server(): + ... from Testing.ZopeTestCase.utils import startZServer + ... ip, port = startZServer() + ... return "http://{}:{}/{}".format(ip, port, portal.id) + + >>> def login(user=TEST_USER_ID, password=TEST_USER_PASSWORD): + ... browser.open(portal_url + "/login_form") + ... browser.getControl(name='__ac_name').value = user + ... browser.getControl(name='__ac_password').value = password + ... browser.getControl(name='submit').click() + ... assert("__ac_password" not in browser.contents) + ... return ploneapi.user.get_current() + + >>> def logout(): + ... browser.open(portal_url + "/logout") + ... assert("You are now logged out" in browser.contents) + + >>> def get_roles_for_permission(permission, context): + ... allowed = set(rolesForPermissionOn(permission, context)) + ... return sorted(allowed) + + >>> def create(container, portal_type, title=None): + ... # Creates a content in a container and manually calls processForm + ... title = title is None and "Test {}".format(portal_type) or title + ... _ = container.invokeFactory(portal_type, id="tmpID", title=title) + ... obj = container.get(_) + ... obj.processForm() + ... modified(obj) # notify explicitly for the test + ... transaction.commit() # somehow the created method did not appear until I added this + ... return obj + + >>> def get_workflows_for(context): + ... # Returns a tuple of assigned workflows for the given context + ... workflow = ploneapi.portal.get_tool("portal_workflow") + ... return workflow.getChainFor(context) + + >>> def get_workflow_status_of(context): + ... # Returns the workflow status of the given context + ... return ploneapi.content.get_state(context) + + +Client +====== + +A `client` lives in the `/clients` folder:: + + >>> clients = portal.clients + >>> client1 = create(clients, "Client", title="Client-1") + >>> client2 = create(clients, "Client", title="Client-2") + + +Contact +======= + +A `contact` lives inside a `client`:: + + >>> contact1 = create(client1, "Contact", "Contact-1") + >>> contact2 = create(client2, "Contact", "Contact-2") + + +User +==== + +A `user` is able to login to the system. + +Create a new user for the contact:: + + >>> user1 = ploneapi.user.create(email="contact-1@example.com", username="user-1", password=TEST_USER_PASSWORD, properties=dict(fullname="Test User 1")) + >>> user2 = ploneapi.user.create(email="contact-2@example.com", username="user-2", password=TEST_USER_PASSWORD, properties=dict(fullname="Test User 2")) + >>> transaction.commit() + + +Client Browser Test +------------------- + +Login with the first user:: + + >>> user = login(user1.id) + +The user is not allowed to access any clients folder:: + + >>> browser.open(clients.absolute_url()) + >>> "client-1" not in browser.contents + True + >>> "client-2" not in browser.contents + True + + >>> browser.open(client1.absolute_url()) + Traceback (most recent call last): + ... + Unauthorized: ... + +Linking the user to a client contact grants access to this client:: + + >>> contact1.setUser(user1) + True + >>> transaction.commit() + +Linking a user adds this user to the `Clients` group:: + + >>> clients_group = ploneapi.group.get("Clients") + >>> user1 in clients_group.getAllGroupMembers() + True + +This gives the user the global `Client` role:: + + >>> sorted(ploneapi.user.get_roles(user=user1)) + ['Authenticated', 'Client', 'Member'] + +It also grants local `Owner` role on the client object:: + + >>> sorted(user1.getRolesInContext(client1)) + ['Authenticated', 'Member', 'Owner'] + +This allows the user to see the client in the clients folder:: + + >>> browser.open(clients.absolute_url()) + >>> "client-1" in browser.contents + True + +But not any other client in the clients folder:: + + >>> "client-2" not in browser.contents + True + +The user is able to modify the `client` object properties:: + + >>> browser.open(client1.absolute_url() + "/base_edit") + >>> "edit_form" in browser.contents + True + +As well as the `contact` object properties:: + + >>> browser.open(contact1.absolute_url() + "/base_edit") + >>> "edit_form" in browser.contents + True + +But the user can not access other clients:: + + >>> browser.open(client2.absolute_url()) + Traceback (most recent call last): + ... + Unauthorized: ... + +Or modify other clients:: + + >>> browser.open(client2.absolute_url() + "/base_edit") + Traceback (most recent call last): + ... + Unauthorized: ... + +Unlink the user revokes all access to the client:: + + >>> contact1.unlinkUser() + True + >>> transaction.commit() + +The user has no local owner role anymore on the client:: + + >>> sorted(user1.getRolesInContext(client1)) + ['Authenticated', 'Member'] + +The user can not access the `client` anymore:: + + >>> browser.open(clients.absolute_url()) + >>> "client-1" not in browser.contents + True + + >>> browser.open(client1.absolute_url()) + Traceback (most recent call last): + ... + Unauthorized: ... + + +Login Details View +------------------ + +The login details view manages to link/unlink users to contacts. + +Get the `login_details` view for the first contact:: + + >>> login_details_view = contact1.restrictedTraverse("login_details") + +The form expects a searchstring coming from the request. We fake it here:: + + >>> login_details_view.searchstring = "" + +Search for linkable users:: + + >>> linkable_users = login_details_view.linkable_users() + >>> linkable_user_ids = map(lambda x: x.get("id"), linkable_users) + +Both users should be in the search results:: + + >>> user1.id in linkable_user_ids + True + + >>> user2.id in linkable_user_ids + True + +This contact is not linked to a user:: + + >>> contact1.hasUser() + False + +Now we link a user over the view:: + + >>> login_details_view._link_user(user1.id) + + >>> contact1.hasUser() + True + +The search should now omit this user from the search, so that it can not be linked anymore:: + + >>> linkable_users = login_details_view.linkable_users() + >>> linkable_user_ids = map(lambda x: x.get("id"), linkable_users) + >>> user1.id in linkable_user_ids + False diff --git a/bika/lims/idserver.py b/bika/lims/idserver.py index 4f85163a74..d3b396e00d 100644 --- a/bika/lims/idserver.py +++ b/bika/lims/idserver.py @@ -1,6 +1,8 @@ +# -*- coding: utf-8 -*- +# # This file is part of Bika LIMS # -# Copyright 2011-2016 by it's authors. +# Copyright 2011-2017 by it's authors. # Some rights reserved. See LICENSE.txt, AUTHORS.txt. import urllib @@ -17,8 +19,6 @@ from bika.lims.numbergenerator import INumberGenerator from zope.component import getAdapters from zope.component import getUtility -from zope.container.interfaces import INameChooser -from zope.interface import implements class IDServerUnavailable(Exception): @@ -55,131 +55,290 @@ def idserver_generate_id(context, prefix, batch_size=None): return new_id -def generateUniqueId(context, parent=False, portal_type=''): - """ Generate pretty content IDs. +def get_objects_in_sequence(brain_or_object, ctype, cref): + """Return a list of items """ + obj = api.get_object(brain_or_object) + if ctype == "backreference": + return get_backreferences(obj, cref) + if ctype == "contained": + return get_contained_items(obj, cref) + raise ValueError("Reference value is mandatory for sequence type counter") - if portal_type == '': - portal_type = context.portal_type - def getLastCounter(context, config): - if config.get('counter_type', '') == 'backreference': - relationship = config['counter_reference'] - backrefs = get_backreferences(context, relationship) - return len(backrefs) - 1 - elif config.get('counter_type', '') == 'contained': - return len(context.objectItems(config['counter_reference'])) - 1 - else: - raise RuntimeError('ID Server: missing values in configuration') +def get_contained_items(obj, spec): + """Returns a list of (id, subobject) tuples of the current context. + If 'spec' is specified, returns only objects whose meta_type match 'spec' + """ + return obj.objectItems(spec) - number_generator = getUtility(INumberGenerator) - # keys = number_generator.keys() - # values = number_generator.values() - # for i in range(len(keys)): - # print '%s : %s' % (keys[i], values[i]) - - def getConfigByPortalType(config_map, portal_type): - config_by_pt = {} - for c in config_map: - if c['portal_type'] == portal_type: - config_by_pt = c - break - return config_by_pt +def get_config(context, **kw): + """Fetch the config dict from the Bika Setup for the given portal_type + """ + # get the ID formatting config config_map = api.get_bika_setup().getIDFormatting() - config = getConfigByPortalType( - config_map=config_map, - portal_type=portal_type) + + # allow portal_type override + portal_type = kw.get("portal_type") or api.get_portal_type(context) + + # check if we have a config for the given portal_type + for config in config_map: + if config['portal_type'] == portal_type: + return config + + # return a default config + default_config = { + 'form': '%s-{seq}' % portal_type.lower(), + 'sequence_type': 'generated', + 'prefix': '%s' % portal_type.lower(), + } + return default_config + + +def get_variables(context, **kw): + """Prepares a dictionary of key->value pairs usable for ID formatting + """ + + # allow portal_type override + portal_type = kw.get("portal_type") or api.get_portal_type(context) + + # The variables map hold the values that might get into the constructed id + variables = { + 'context': context, + 'id': api.get_id(context), + 'portal_type': portal_type, + 'year': get_current_year(), + 'parent': api.get_parent(context), + 'seq': 0, + } + + # Augment the variables map depending on the portal type if portal_type == "AnalysisRequest": - variables_map = { + variables.update({ 'sampleId': context.getSample().getId(), 'sample': context.getSample(), - 'year': DateTime().strftime("%Y")[2:], - } + }) + elif portal_type == "SamplePartition": - variables_map = { + variables.update({ 'sampleId': context.aq_parent.getId(), 'sample': context.aq_parent, - 'year': DateTime().strftime("%Y")[2:], - } - elif portal_type == "Sample" and parent: - config = getConfigByPortalType( - config_map=config_map, - portal_type='SamplePartition') # Override - variables_map = { - 'sampleId': context.getId(), - 'sample': context, - 'year': DateTime().strftime("%Y")[2:], - } + }) + elif portal_type == "Sample": - sampleDate = None - sampleType = context.getSampleType().getPrefix() - if context.getDateSampled(): - sampleDate = DT2dt(context.getDateSampled()) + # get the prefix of the assigned sample type + sample_id = context.getId() + sample_type = context.getSampleType() + sampletype_prefix = sample_type.getPrefix() + + date_now = DateTime() + sampling_date = context.getSamplingDate() + date_sampled = context.getDateSampled() + + # Try to get the date sampled and sampling date + if sampling_date: + samplingDate = DT2dt(sampling_date) + else: + # No Sample Date? + logger.error("Sample {} has no sample date set".format(sample_id)) + # fall back to current date + samplingDate = DT2dt(date_now) + + if date_sampled: + dateSampled = DT2dt(date_sampled) else: # No Sample Date? - logger.error("Sample {} has no sample date set".format( - context.getId())) - sampleDate = DT2dt(DateTime()) + logger.error("Sample {} has no sample date set".format(sample_id)) + dateSampled = DT2dt(date_now) - variables_map = { + variables.update({ 'clientId': context.aq_parent.getClientID(), - 'sampleDate': sampleDate, - 'sampleType': api.normalize_filename(sampleType), - 'year': DateTime().strftime("%Y")[2:], - } + 'dateSampled': dateSampled, + 'samplingDate': samplingDate, + 'sampleType': sampletype_prefix, + }) + + return variables + + +def split(string, separator="-"): + """ split a string on the given separator + """ + if not isinstance(string, basestring): + return [] + return string.split(separator) + + +def to_int(thing, default=0): + """Convert a thing to an integer + """ + try: + return int(thing) + except (TypeError, ValueError): + return default + + +def slice(string, separator="-", start=None, end=None): + """Slice out a segment of a string, which is splitted on separator. + """ + + # split the given string at the given separator + segments = split(string, separator) + + # get the start and endposition for slicing + length = len(segments) + start = to_int(start) + end = to_int(end, length) + + # return the separator joined sliced segments + sliced_parts = segments[start:end] + return separator.join(sliced_parts) + + +def get_current_year(): + """Returns the current year as a two digit string + """ + return DateTime().strftime("%Y")[2:] + + +def search_by_prefix(portal_type, prefix): + """Returns brains which share the same portal_type and ID prefix + """ + catalog = api.get_tool("portal_catalog") + brains = catalog({"portal_type": portal_type}) + # Filter brains with the same ID prefix + return filter(lambda brain: api.get_id(brain).startswith(prefix), brains) + + +def get_ids_with_prefix(portal_type, prefix): + """Return a list of ids sharing the same portal type and prefix + """ + brains = search_by_prefix(portal_type, prefix) + ids = map(api.get_id, brains) + return ids + + +def get_counted_number(context, config, variables, **kw): + """Compute the number for the sequence type "Counter" + """ + # This "context" is defined by the user in Bika Setup and can be actually + # anything. However, we assume it is something like "sample" or similar + ctx = config.get("context") + + # get object behind the context name (falls back to the current context) + obj = variables.get(ctx, context) + + # get the counter type, which is either "backreference" or "contained" + counter_type = config.get("counter_type") + + # the counter reference is either the "relationship" for + # "backreference" or the meta type for contained objects + counter_reference = config.get("counter_reference") + + # This should be a list of existing items, including the current context + # object + seq_items = get_objects_in_sequence(obj, counter_type, counter_reference) + + number = len(seq_items) + return number + + +def get_generated_number(context, config, variables, **kw): + """Generate a new persistent number with the number generator for the + sequence type "Generated" + """ + # allow portal_type override + portal_type = kw.get("portal_type") or api.get_portal_type(context) + + # The ID format for string interpolation, e.g. WS-{seq:03d} + id_template = config.get("form", "") + + # The split length defines where the variable part of the ID template begins + split_length = config.get("split_length", 1) + + # The prefix tempalte is the static part of the ID + prefix_template = slice(id_template, end=split_length) + + # get the number generator + number_generator = getUtility(INumberGenerator) + + # generate the key for the number generator storage + prefix = prefix_template.format(**variables) + key = portal_type.lower() + if prefix: + key = "{}-{}".format(key, prefix) + + # XXX: Handle flushed storage - refactoring needed here! + if key not in number_generator: + # we need to figure out the current state of the DB. + existing = search_by_prefix(portal_type, prefix) + max_num = 0 + for brain in existing: + num = to_int(slice(api.get_id(brain), start=split_length)) + if num > max_num: + max_num = num + # set the number generator + number_generator.set_number(key, max_num) + + # TODO: We need a way to figure out the max numbers allowed in this + # sequence to raise a KeyError when the current number exceeds the maximum + # number possible in the sequence + + # TODO: This allows us to "preview" the next generated ID in the UI + if not kw.get("dry_run", False): + # generate a new number + number = number_generator.generate_number(key=key) else: - if not config: - # Provide default if no format specified on bika_setup - config = { - 'form': '%s-{seq}' % portal_type.lower(), - 'sequence_type': 'generated', - 'prefix': '%s' % portal_type.lower(), - } - variables_map = { - 'year': DateTime().strftime("%Y")[2:], - } - - # Actual id construction starts here - new_seq = 0 - form = config['form'] - if config['sequence_type'] == 'counter': - new_seq = getLastCounter( - context=variables_map[config['context']], - config=config) - elif config['sequence_type'] == 'generated': - try: - if config.get('split_length', None) == 0: - prefix_config = '-'.join(form.split('-')[:-1]) - prefix = prefix_config.format(**variables_map) - elif config.get('split_length', 0) > 0: - prefix_config = form.split('-')[:config['split_length']] - prefix_config = '-'.join(prefix_config) - prefix = prefix_config.format(**variables_map) - else: - prefix = config['prefix'] - new_seq = number_generator(key=prefix) - except KeyError, e: - msg = "KeyError in GenerateUniqueId on %s: %s" % ( - str(config), e) - raise RuntimeError(msg) - except ValueError, e: - msg = "ValueError in GenerateUniqueId on %s with %s: %s" % ( - str(config), str(variables_map), e) - raise RuntimeError(msg) - variables_map['seq'] = new_seq + 1 - result = form.format(**variables_map) - return result + # just fetch the next number + number = number_generator.get(key, 1) + return number -def renameAfterCreation(obj): +def generateUniqueId(context, **kw): + """ Generate pretty content IDs. """ - Renaming object right after it is created... - In some cases we may want to override generate_id function. To do this, - an adapter must be added (providing bika.lims.interfaces.IIdServer) for - that content type. + + # get the config for this portal type from the system setup + config = get_config(context, **kw) + + # get the variables map for later string interpolation + variables = get_variables(context, **kw) + + # The new generate sequence number + number = 0 + + # get the sequence type from the global config + sequence_type = config.get("sequence_type", "generated") + + # Sequence Type is "Counter", so we use the length of the backreferences or + # contained objects of the evaluated "context" defined in the config + if sequence_type == 'counter': + number = get_counted_number(context, config, variables, **kw) + + # Sequence Type is "Generated", so the ID is constructed according to the + # configured split length + if sequence_type == 'generated': + number = get_generated_number(context, config, variables, **kw) + + # store the new sequence number to the variables map for str interpolation + variables["seq"] = number + + # The ID formatting template from user config, e.g. {sampleId}-R{seq:02d} + id_template = config.get("form", "") + + # Interpolate the ID template + new_id = id_template.format(**variables) + normalized_id = api.normalize_filename(new_id) + logger.info("generateUniqueId: {}".format(normalized_id)) + + return normalized_id + + +def renameAfterCreation(obj): + """Rename the content after it was created/added """ - # Check if the _bika_id was aready set + # Check if the _bika_id was already set bika_id = getattr(obj, "_bika_id", None) if bika_id is not None: return bika_id @@ -197,6 +356,11 @@ def renameAfterCreation(obj): if not new_id: new_id = generateUniqueId(obj) + # TODO: This is a naive check just in current folder + # -> this should check globally for duplicate objects with same prefix + # N.B. a check like `search_by_prefix` each time would probably slow things + # down too much! + # -> A solution could be to store all IDs with a certain prefix in a storage parent = api.get_parent(obj) if new_id in parent.objectIds(): # XXX We could do the check in a `while` loop and generate a new one. @@ -206,18 +370,3 @@ def renameAfterCreation(obj): parent.manage_renameObject(obj.id, new_id) return new_id - - -class AutoGenerateID(object): - implements(INameChooser) - - def __init__(self, context): - self.context = context - - def chooseName(self, name, object): - bika_id = getattr(object, "_bika_id", None) - if bika_id is None: - new_id = generateUniqueId(object) - object._bika_id = new_id - bika_id = new_id - return bika_id diff --git a/bika/lims/numbergenerator.py b/bika/lims/numbergenerator.py index 8c69fe6092..3d200cef9a 100644 --- a/bika/lims/numbergenerator.py +++ b/bika/lims/numbergenerator.py @@ -69,6 +69,15 @@ def values(self): out.append(value) return out + def __iter__(self): + return self.storage.__iter__() + + def __getitem__(self, key): + return self.storage.__getitem__(key) + + def get(self, key, default=None): + return self.storage.get(key, default) + def get_number(self, key): """ get the next consecutive number """ @@ -82,7 +91,7 @@ def get_number(self, key): counter = storage[key] storage[key] = counter + 1 except KeyError: - storage[key] = 0 + storage[key] = 1 finally: logger.debug("*** consecutive number lock release ***") self.storage._p_changed = True @@ -91,6 +100,25 @@ def get_number(self, key): logger.debug("NUMBER after => %s" % storage.get(key, '-')) return storage[key] + def set_number(self, key, value): + """ set a key's value + """ + storage = self.storage + + if not isinstance(value, int): + logger.error("set_number: Value must be an integer") + return + + try: + lock.acquire() + storage[key] = value + finally: + self.storage._p_changed = True + lock.release() + + return storage[key] + + def generate_number(self, key="default"): """ get a number """ @@ -98,4 +126,3 @@ def generate_number(self, key="default"): def __call__(self, key="default"): return self.generate_number(key) - diff --git a/bika/lims/tests/doctests/IDServer.rst b/bika/lims/tests/doctests/IDServer.rst index 1cf1d40ec0..e831ecb3ba 100644 --- a/bika/lims/tests/doctests/IDServer.rst +++ b/bika/lims/tests/doctests/IDServer.rst @@ -1,18 +1,20 @@ ID Server ========= -The ID Server in Bika LIMS provides IDs for content items base of the given format -specification. The format string is constructed in the same way as a python format() -method based predefined variables per content type. The only variable available to -all type is 'seq'. Currently, seq can be constructed either using number generator -or a counter of existing items. For generated IDs, one can specifypoint at which -the format string will be split to create the generator key. For counter IDs, one -must specify context and the type of counter which is either the number of -backreferences or the number of contained objects. +The ID Server in Bika LIMS provides IDs for content items base of the given +format specification. The format string is constructed in the same way as a +python format() method based predefined variables per content type. The only +variable available to all type is 'seq'. Currently, seq can be constructed +either using number generator or a counter of existing items. For generated IDs, +one can specifypoint at which the format string will be split to create the +generator key. For counter IDs, one must specify context and the type of +counter which is either the number of backreferences or the number of contained +objects. Configuration Settings: * format: - - a python format string constructed from predefined variables like sampleId, client, sampleType. + - a python format string constructed from predefined variables like sampleId, + client, sampleType. - special variable 'seq' must be positioned last in the format string * sequence type: [generated|counter] * context: if type counter, provides context the counting function @@ -21,7 +23,10 @@ Configuration Settings: * prefix: default prefix if none provided in format string * split length: the number of parts to be included in the prefix -Running this test from the buildout directory: +ToDo: +* validation of format strings + +Running this test from the buildout directory:: bin/test -t IDServer @@ -29,7 +34,7 @@ Running this test from the buildout directory: Test Setup ---------- -Needed Imports: +Needed Imports:: >>> import transaction >>> from DateTime import DateTime @@ -38,7 +43,7 @@ Needed Imports: >>> from bika.lims import api >>> from bika.lims.utils.analysisrequest import create_analysisrequest -Functional Helpers: +Functional Helpers:: >>> def start_server(): ... from Testing.ZopeTestCase.utils import startZServer @@ -48,7 +53,7 @@ Functional Helpers: >>> def timestamp(format="%Y-%m-%d"): ... return DateTime().strftime(format) -Variables: +Variables:: >>> date_now = timestamp() >>> year = date_now.split('-')[0][2:] @@ -66,8 +71,10 @@ Variables: >>> bika_sampleconditions = bika_setup.bika_sampleconditions >>> portal_url = portal.absolute_url() >>> bika_setup_url = portal_url + "/bika_setup" + >>> browser = self.getBrowser() + >>> current_user = ploneapi.user.get_current() -Test user: +Test user:: We need certain permissions to create and access objects used in this test, so here we will assume the role of Lab Manager. @@ -80,78 +87,79 @@ so here we will assume the role of Lab Manager. Analysis Requests (AR) ---------------------- -An `AnalysisRequest` can only be created inside a `Client`: +An `AnalysisRequest` can only be created inside a `Client`:: >>> clients = self.portal.clients >>> client = api.create(clients, "Client", Name="RIDING BYTES", ClientID="RB") >>> client <...client-1> -To create a new AR, a `Contact` is needed: +To create a new AR, a `Contact` is needed:: >>> contact = api.create(client, "Contact", Firstname="Ramon", Surname="Bartl") >>> contact <...contact-1> A `SampleType` defines how long the sample can be retained, the minimum volume -needed, if it is hazardous or not, the point where the sample was taken etc.: +needed, if it is hazardous or not, the point where the sample was taken etc.:: >>> sampletype = api.create(bika_sampletypes, "SampleType", Prefix="water") >>> sampletype <...sampletype-1> -A `SamplePoint` defines the location, where a `Sample` was taken: +A `SamplePoint` defines the location, where a `Sample` was taken:: >>> samplepoint = api.create(bika_samplepoints, "SamplePoint", title="Lake of Constance") >>> samplepoint <...samplepoint-1> -An `AnalysisCategory` categorizes different `AnalysisServices`: +An `AnalysisCategory` categorizes different `AnalysisServices`:: >>> analysiscategory = api.create(bika_analysiscategories, "AnalysisCategory", title="Water") >>> analysiscategory <...analysiscategory-1> -An `AnalysisService` defines a analysis service offered by the laboratory: +An `AnalysisService` defines a analysis service offered by the laboratory:: >>> analysisservice = api.create(bika_analysisservices, "AnalysisService", ... title="PH", Category=analysiscategory, Keyword="PH") >>> analysisservice <...analysisservice-1> -Set up `ID Server` configuration: - - >>> values = [{'form': '{sampleType}{year}-{seq:04d}', - ... 'portal_type': 'Sample', - ... 'prefix': 'sample', - ... 'sequence_type': 'generated', - ... 'split_length': 1, - ... 'value': ''}, - ... {'context': 'sample', - ... 'counter_reference': 'AnalysisRequestSample', - ... 'counter_type': 'backreference', - ... 'form': '{sampleId}-R{seq:d}', - ... 'portal_type': 'AnalysisRequest', - ... 'sequence_type': 'counter', - ... 'value': ''}, - ... {'context': 'sample', - ... 'counter_reference': 'SamplePartition', - ... 'counter_type': 'contained', - ... 'form': '{sampleId}-P{seq:d}', - ... 'portal_type': 'SamplePartition', - ... 'sequence_type': 'counter', - ... 'value': ''}, - ... {'form': 'B{year}-{seq:04d}', - ... 'portal_type': 'Batch', - ... 'prefix': 'batch', - ... 'sequence_type': 'generated', - ... 'split_length': 1, - ... 'value': ''}, +Set up `ID Server` configuration:: + + >>> values = [ + ... {'form': '{sampleType}{year}-{seq:04d}', + ... 'portal_type': 'Sample', + ... 'prefix': 'sample', + ... 'sequence_type': 'generated', + ... 'split_length': 1, + ... 'value': ''}, + ... {'context': 'sample', + ... 'counter_reference': 'AnalysisRequestSample', + ... 'counter_type': 'backreference', + ... 'form': '{sampleId}-R{seq:d}', + ... 'portal_type': 'AnalysisRequest', + ... 'sequence_type': 'counter', + ... 'value': ''}, + ... {'context': 'sample', + ... 'counter_reference': 'SamplePartition', + ... 'counter_type': 'contained', + ... 'form': '{sampleId}-P{seq:d}', + ... 'portal_type': 'SamplePartition', + ... 'sequence_type': 'counter', + ... 'value': ''}, + ... {'form': 'BA-{year}-{seq:04d}', + ... 'portal_type': 'Batch', + ... 'prefix': 'batch', + ... 'sequence_type': 'generated', + ... 'split_length': 1, + ... 'value': ''}, ... ] >>> bika_setup.setIDFormatting(values) -An `AnalysisRequest` can be created: +An `AnalysisRequest` can be created:: >>> values = {'Client': client.UID(), ... 'Contact': contact.UID(), @@ -160,12 +168,14 @@ An `AnalysisRequest` can be created: ... 'SampleType': sampletype.UID(), ... } + >>> ploneapi.user.grant_roles(user=current_user,roles = ['Sampler', 'LabClerk']) + >>> transaction.commit() >>> service_uids = [analysisservice.UID()] >>> ar = create_analysisrequest(client, request, values, service_uids) >>> ar <...water17-0001-R1> -Create a second `AnalysisRequest`: +Create a second `AnalysisRequest`:: >>> values = {'Client': client.UID(), ... 'Contact': contact.UID(), @@ -179,7 +189,7 @@ Create a second `AnalysisRequest`: >>> ar <...water17-0002-R1> -Create a third `AnalysisRequest` with existing sample: +Create a third `AnalysisRequest` with existing sample:: >>> sample = ar.getSample() >>> sample @@ -198,31 +208,37 @@ Create a third `AnalysisRequest` with existing sample: Create a forth `Batch`:: >>> batches = self.portal.batches >>> batch = api.create(batches, "Batch", ClientID="RB") - >>> batch.getId() == "B{}-0001".format(year) + >>> batch.getId() == "BA-{}-0001".format(year) True -Change ID formats and create new `AnalysisRequest`: - - >>> values = [{'form': '{clientId}-{sampleDate:%Y%m%d}-{sampleType}-{seq:04d}', - ... 'portal_type': 'Sample', - ... 'prefix': 'sample', - ... 'sequence_type': 'generated', - ... 'split_length': 2, - ... 'value': ''}, - ... {'context': 'sample', - ... 'counter_reference': 'AnalysisRequestSample', - ... 'counter_type': 'backreference', - ... 'form': '{sampleId}-R{seq:03d}', - ... 'portal_type': 'AnalysisRequest', - ... 'sequence_type': 'counter', - ... 'value': ''}, - ... {'context': 'sample', - ... 'counter_reference': 'SamplePartition', - ... 'counter_type': 'contained', - ... 'form': '{sampleId}-P{seq:d}', - ... 'portal_type': 'SamplePartition', - ... 'sequence_type': 'counter', - ... 'value': ''}, +Change ID formats and create new `AnalysisRequest`:: + >>> values = [ + ... {'form': '{clientId}-{dateSampled:%Y%m%d}-{sampleType}-{seq:04d}', + ... 'portal_type': 'Sample', + ... 'prefix': 'sample', + ... 'sequence_type': 'generated', + ... 'split_length': 2, + ... 'value': ''}, + ... {'context': 'sample', + ... 'counter_reference': 'AnalysisRequestSample', + ... 'counter_type': 'backreference', + ... 'form': '{sampleId}-R{seq:03d}', + ... 'portal_type': 'AnalysisRequest', + ... 'sequence_type': 'counter', + ... 'value': ''}, + ... {'context': 'sample', + ... 'counter_reference': 'SamplePartition', + ... 'counter_type': 'contained', + ... 'form': '{sampleId}-P{seq:d}', + ... 'portal_type': 'SamplePartition', + ... 'sequence_type': 'counter', + ... 'value': ''}, + ... {'form': 'BA-{year}-{seq:04d}', + ... 'portal_type': 'Batch', + ... 'prefix': 'batch', + ... 'sequence_type': 'generated', + ... 'split_length': 1, + ... 'value': ''}, ... ] >>> bika_setup.setIDFormatting(values) @@ -237,4 +253,14 @@ Change ID formats and create new `AnalysisRequest`: >>> service_uids = [analysisservice.UID()] >>> ar = create_analysisrequest(client, request, values, service_uids) >>> ar - + + +Re-seed and create a new `Batch`:: + >>> ploneapi.user.grant_roles(user=current_user,roles = ['Manager']) + >>> transaction.commit() + >>> browser.open(portal_url + '/ng_seed?prefix=batch-BA&seed=10') + >>> batch = api.create(batches, "Batch", ClientID="RB") + >>> batch.getId() == "BA-{}-0011".format(year) + True + +TODO: Test the case when numbers are exhausted in a sequence! diff --git a/bika/lims/tests/doctests/InstrumentCalibrationCertificationAndValidation.rst b/bika/lims/tests/doctests/InstrumentCalibrationCertificationAndValidation.rst index 6db407a796..cab843c3de 100644 --- a/bika/lims/tests/doctests/InstrumentCalibrationCertificationAndValidation.rst +++ b/bika/lims/tests/doctests/InstrumentCalibrationCertificationAndValidation.rst @@ -1,4 +1,3 @@ -==================================================== Instrument Calibration, Certification and Validation ==================================================== diff --git a/bika/lims/utils/sample.py b/bika/lims/utils/sample.py index 34e48cd9ae..9ba4d41a8a 100644 --- a/bika/lims/utils/sample.py +++ b/bika/lims/utils/sample.py @@ -30,6 +30,9 @@ def create_sample(client, request, values): # Specifically set the storage location if 'StorageLocation' in values: sample.setStorageLocation(values['StorageLocation']) + # Specifically set the DateSampled + if 'DateSampled' in values: + sample.setDateSampled(values['DateSampled']) # Update the created sample with indicated values sample.processForm(REQUEST=request, values=values) # Set the SampleID diff --git a/bika/lims/validators.py b/bika/lims/validators.py index 0382e7f092..ba2383ca05 100644 --- a/bika/lims/validators.py +++ b/bika/lims/validators.py @@ -120,11 +120,11 @@ def __call__(self, value, *args, **kwargs): # likely very expensive if the parent object contains many objects if fieldname in catalog.indexes(): # We use the fieldname as index to reduce the results list - catalog_query[fieldname] = safe_unicode(value) + catalog_query[fieldname] = to_utf8(safe_unicode(value)) parent_objects = map(api.get_object, catalog(catalog_query)) elif field_index and field_index in catalog.indexes(): # We use the field index to reduce the results list - catalog_query[field_index] = safe_unicode(value) + catalog_query[field_index] = to_utf8(safe_unicode(value)) parent_objects = map(api.get_object, catalog(catalog_query)) else: # fall back to the objectValues :(