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 Type | Variables | "
+ "
"
+ "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 :(