{ "info": { "author": "SENAITE Foundation", "author_email": "hello@senaite.com", "bugtrack_url": null, "classifiers": [ "Framework :: Plone", "Framework :: Zope2", "Programming Language :: Python" ], "description": ".. image:: https://raw.githubusercontent.com/senaite/senaite.api/master/static/api-logo.png\n :alt: senaite.api\n :height: 64 px\n :align: center\n\n- **SENAITE.API**: *The Swiss-Army-Knife for SENAITE Core and Add-on developers*\n\n.. image:: https://img.shields.io/pypi/v/senaite.api.svg?style=flat-square\n :target: https://pypi.python.org/pypi/senaite.api\n\n.. image:: https://img.shields.io/github/issues-pr/senaite/senaite.api.svg?style=flat-square\n :target: https://github.com/senaite/senaite.api/pulls\n\n.. image:: https://img.shields.io/github/issues/senaite/senaite.api.svg?style=flat-square\n :target: https://github.com/senaite/senaite.api/issues\n\n.. image:: https://img.shields.io/badge/README-GitHub-blue.svg?style=flat-square\n :target: https://github.com/senaite/senaite.api#readme\n\nAbout\n=====\n\nSENAITE API is the Swiss-Army-Knife for SENAITE Core and Add-on developers. It\nprovides a sane interface for common tasks in SENAITE, like e.g. object\ncreation, lookup by ID/UID, search etc.\n\nPlease see the doctests for further details and usage:\n\n- `Core API Documentation`_\n- `Analysis API Documentation`_\n\n\nInstallation\n============\n\nPlease follow the installations instructions for `Plone 4`_ and\n`senaite.lims`_.\n\nTo install SENAITE API, you have to add `senaite.api` into the\n`eggs` list inside the `[buildout]` section of your\n`buildout.cfg`::\n\n [buildout]\n parts =\n instance\n extends =\n http://dist.plone.org/release/4.3.17/versions.cfg\n find-links =\n http://dist.plone.org/release/4.3.17\n http://dist.plone.org/thirdparty\n eggs =\n Plone\n Pillow\n senaite.lims\n senaite.api\n zcml =\n eggs-directory = ${buildout:directory}/eggs\n\n [instance]\n recipe = plone.recipe.zope2instance\n user = admin:admin\n http-address = 0.0.0.0:8080\n eggs =\n ${buildout:eggs}\n zcml =\n ${buildout:zcml}\n\n [versions]\n setuptools =\n zc.buildout =\n\n\n**Note**\n\nThe above example works for the buildout created by the unified\ninstaller. If you however have a custom buildout you might need to add\nthe egg to the `eggs` list in the `[instance]` section rather than\nadding it in the `[buildout]` section.\n\nAlso see this section of the Plone documentation for further details:\nhttps://docs.plone.org/4/en/manage/installing/installing_addons.html\n\n**Important**\n\nFor the changes to take effect you need to re-run buildout from your\nconsole::\n\n bin/buildout\n\n\n.. _Plone 4: https://docs.plone.org/4/en/manage/installing/index.html\n.. _senaite.lims: https://github.com/senaite/senaite.lims#installation\n.. _Core API Documentation: https://github.com/senaite/senaite.api/blob/master/src/senaite/api/docs/API.rst\n.. _Analysis API Documentation: https://github.com/senaite/senaite.api/blob/master/src/senaite/api/docs/API_analysis.rst\n\n\nSENAITE API DOCTEST\n===================\n\nThe SENAITE LIMS API provides single functions for single purposes.\nThis Test builds completely on the API without any further imports needed.\n\nRunning this test from the buildout directory::\n\n bin/test test_doctests -t API\n\nIntroduction\n------------\n\nThe purpose of this API is to help coders to follow the DRY principle (Don't\nRepeat Yourself). It also ensures that the most effective and efficient method is\nused to achieve a task.\n\nImport it first::\n\n >>> from senaite import api\n\n\nGetting the Portal\n------------------\n\nThe Portal is the SENAITE LIMS root object::\n\n >>> portal = api.get_portal()\n >>> portal\n \n\n\nGetting the Setup object\n------------------------\n\nThe Setup object gives access to all of the Bika configuration settings::\n\n >>> bika_setup = api.get_setup()\n >>> bika_setup\n \n\n\nCreating new Content\n--------------------\n\nCreating new contents in Bika LIMS requires some special knowledge.\nThis function helps to do it right and creates a content for you.\n\nHere we create a new `Client` in the `plone/clients` folder::\n\n >>> client = api.create(portal.clients, \"Client\", title=\"Test Client\")\n >>> client\n \n\n >>> client.Title()\n 'Test Client'\n\n\nGetting a Tool\n--------------\n\nThere are many ways to get a tool in Bika LIMS / Plone. This function\ncentralizes this functionality and makes it painless::\n\n >>> api.get_tool(\"bika_setup_catalog\")\n \n\nTrying to fetch an non-existing tool raises a custom `SenaiteAPIError`::\n\n >>> api.get_tool(\"NotExistingTool\")\n Traceback (most recent call last):\n [...]\n SenaiteAPIError: No tool named 'NotExistingTool' found.\n\nThis error can also be used for custom methods with the `fail` function::\n\n >>> api.fail(\"This failed badly\")\n Traceback (most recent call last):\n [...]\n SenaiteAPIError: This failed badly\n\n\nGetting an Object\n-----------------\n\nGetting a tool from a catalog brain is a common task in Bika LIMS. This function\nprovides an unified interface to portal objects **and** brains.\nFurthermore it is idempotent, so it can be called multiple times in a row.\n\nWe will demonstrate the usage on the client object we created above::\n\n >>> api.get_object(client)\n \n\n >>> api.get_object(api.get_object(client))\n \n\nNow we show it with catalog results::\n\n >>> portal_catalog = api.get_tool(\"portal_catalog\")\n >>> brains = portal_catalog(portal_type=\"Client\")\n >>> brains\n []\n\n >>> brain = brains[0]\n\n >>> api.get_object(brain)\n \n\n >>> api.get_object(api.get_object(brain))\n \n\nNo supported objects raise an error::\n\n >>> api.get_object(object())\n Traceback (most recent call last):\n [...]\n SenaiteAPIError: is not supported.\n\nTo check if an object is supported, e.g. is an ATCT, Dexterity, ZCatalog or\nPortal object, we can use the `is_object` function::\n\n >>> api.is_object(client)\n True\n\n >>> api.is_object(brain)\n True\n\n >>> api.is_object(api.get_portal())\n True\n\n >>> api.is_object(None)\n False\n\n >>> api.is_object(object())\n False\n\n\nChecking if an Object is the Portal\n-----------------------------------\n\nSometimes it can be handy to check if the current object is the portal::\n\n >>> api.is_portal(portal)\n True\n\n >>> api.is_portal(client)\n False\n\n >>> api.is_portal(object())\n False\n\n\nChecking if an Object is a Catalog Brain\n----------------------------------------\n\nKnowing if we have an object or a brain can be handy. This function checks this for you::\n\n >>> api.is_brain(brain)\n True\n\n >>> api.is_brain(api.get_object(brain))\n False\n\n >>> api.is_brain(object())\n False\n\n\nChecking if an Object is a Dexterity Content\n--------------------------------------------\n\nThis function checks if an object is a `Dexterity` content type::\n\n >>> api.is_dexterity_content(client)\n False\n\n >>> api.is_dexterity_content(portal)\n False\n\nWe currently have no `Dexterity` contents, so testing this comes later...\n\n\nChecking if an Object is an AT Content\n--------------------------------------\n\nThis function checks if an object is an `Archetypes` content type::\n\n >>> api.is_at_content(client)\n True\n\n >>> api.is_at_content(portal)\n False\n\n >>> api.is_at_content(object())\n False\n\n\nGetting the Schema of a Content\n-------------------------------\n\nThe schema contains the fields of a content object. Getting the schema is a\ncommon task, but differs between `ATContentType` based objects and `Dexterity`\nbased objects. This function brings it under one umbrella::\n\n >>> schema = api.get_schema(client)\n >>> schema\n \n\nCatalog brains are also supported::\n\n >>> api.get_schema(brain)\n \n\n\nGetting the Fields of a Content\n-------------------------------\n\nThe fields contain all the values that an object holds and are therefore\nresponsible for getting and setting the information.\n\nThis function returns the fields as a dictionary mapping of `{\"key\": value}`::\n\n >>> fields = api.get_fields(client)\n >>> fields.get(\"ClientID\")\n \n\nCatalog brains are also supported::\n\n >>> api.get_fields(brain).get(\"ClientID\")\n \n\n\nGetting the ID of a Content\n---------------------------\n\nGetting the ID is a common task in Bika LIMS.\nThis function takes care that catalog brains are not waked up for this task::\n\n >>> api.get_id(portal)\n 'plone'\n\n >>> api.get_id(client)\n 'client-1'\n\n >>> api.get_id(brain)\n 'client-1'\n\n\nGetting the Title of a Content\n------------------------------\n\nGetting the Title is a common task in Bika LIMS.\nThis function takes care that catalog brains are not waked up for this task::\n\n >>> api.get_title(portal)\n u'Plone site'\n\n >>> api.get_title(client)\n 'Test Client'\n\n >>> api.get_title(brain)\n 'Test Client'\n\n\nGetting the Description of a Content\n------------------------------------\n\nGetting the Description is a common task in Bika LIMS.\nThis function takes care that catalog brains are not waked up for this task::\n\n >>> api.get_description(portal)\n ''\n\n >>> api.get_description(client)\n ''\n\n >>> api.get_description(brain)\n ''\n\n\nGetting the UID of a Content\n----------------------------\n\nGetting the UID is a common task in Bika LIMS.\nThis function takes care that catalog brains are not waked up for this task.\n\nThe portal object actually has no UID. This funciton defines it therfore to be `0`::\n\n >>> api.get_uid(portal)\n '0'\n\n >>> uid_client = api.get_uid(client)\n >>> uid_client_brain = api.get_uid(brain)\n >>> uid_client is uid_client_brain\n True\n\n\nGetting the URL of a Content\n----------------------------\n\nGetting the URL is a common task in Bika LIMS.\nThis function takes care that catalog brains are not waked up for this task::\n\n >>> api.get_url(portal)\n 'http://nohost/plone'\n\n >>> api.get_url(client)\n 'http://nohost/plone/clients/client-1'\n\n >>> api.get_url(brain)\n 'http://nohost/plone/clients/client-1'\n\n\nGetting the Icon of a Content\n-----------------------------\n\n::\n\n >>> api.get_icon(client)\n ''\n\n >>> api.get_icon(brain)\n ''\n\n >>> api.get_icon(client, html_tag=False)\n 'http://nohost/plone/++resource++bika.lims.images/client.png'\n\n >>> api.get_icon(client, html_tag=False)\n 'http://nohost/plone/++resource++bika.lims.images/client.png'\n\n\nGetting an object by UID\n------------------------\n\nThis function finds an object by its uinique ID (UID).\nThe portal object with the defined UId of '0' is also supported::\n\n >>> api.get_object_by_uid('0')\n \n\n >>> api.get_object_by_uid(uid_client)\n \n\n >>> api.get_object_by_uid(uid_client_brain)\n \n\nIf a default value is provided, the function will never fail. Any exception\nor error will result in the default value being returned::\n\n >>> api.get_object_by_uid('invalid uid', 'default')\n 'default'\n\n >>> api.get_object_by_uid(None, 'default')\n 'default'\n\n\nGetting an object by Path\n-------------------------\n\nThis function finds an object by its physical path::\n\n >>> api.get_object_by_path('/plone')\n \n\n >>> api.get_object_by_path('/plone/clients/client-1')\n \n\nPaths outside the portal raise an error::\n\n >>> api.get_object_by_path('/root')\n Traceback (most recent call last):\n [...]\n SenaiteAPIError: Not a physical path inside the portal.\n\nAny exception returns default value::\n\n >>> api.get_object_by_path('/invaid/path', 'default')\n 'default'\n\n >>> api.get_object_by_path(None, 'default')\n 'default'\n\n\nGetting the Physical Path of an Object\n--------------------------------------\n\nThe physical path describes exactly where an object is located inside the portal.\nThis function unifies the different approaches to get the physical path and does\nso in the most efficient way::\n\n >>> api.get_path(portal)\n '/plone'\n\n >>> api.get_path(client)\n '/plone/clients/client-1'\n\n >>> api.get_path(brain)\n '/plone/clients/client-1'\n\n >>> api.get_path(object())\n Traceback (most recent call last):\n [...]\n SenaiteAPIError: is not supported.\n\n\nGetting the Physical Parent Path of an Object\n---------------------------------------------\n\nThis function returns the physical path of the parent object::\n\n >>> api.get_parent_path(client)\n '/plone/clients'\n\n >>> api.get_parent_path(brain)\n '/plone/clients'\n\nHowever, this function goes only up to the portal object::\n\n >>> api.get_parent_path(portal)\n '/plone'\n\nLike with the other functions, only portal objects are supported::\n\n >>> api.get_parent_path(object())\n Traceback (most recent call last):\n [...]\n SenaiteAPIError: is not supported.\n\n\nGetting the Parent Object\n-------------------------\n\nThis function returns the parent object::\n\n >>> api.get_parent(client)\n \n\nBrains are also supported::\n\n >>> api.get_parent(brain)\n \n\nThe function can also use a catalog query on the `portal_catalog` and return a\nbrain, if the passed parameter `catalog_search` was set to true. ::\n\n >>> api.get_parent(client, catalog_search=True)\n \n\n >>> api.get_parent(brain, catalog_search=True)\n \n\nHowever, this function goes only up to the portal object::\n\n >>> api.get_parent(portal)\n \n\nLike with the other functions, only portal objects are supported::\n\n >>> api.get_parent(object())\n Traceback (most recent call last):\n [...]\n SenaiteAPIError: is not supported.\n\n\nSearching Objects\n-----------------\n\nSearching in Bika LIMS requires knowledge in which catalog the object is indexed.\nThis function unifies all Bika LIMS catalog to a single search interface::\n\n >>> results = api.search({'portal_type': 'Client'})\n >>> results\n []\n\nMultiple content types are also supported::\n\n >>> results = api.search({'portal_type': ['Client', 'ClientFolder'], 'sort_on': 'getId'})\n >>> map(api.get_id, results)\n ['client-1', 'clients']\n\nNow we create some objects which are located in the `bika_setup_catalog`::\n\n >>> instruments = bika_setup.bika_instruments\n >>> instrument1 = api.create(instruments, \"Instrument\", title=\"Instrument-1\")\n >>> instrument2 = api.create(instruments, \"Instrument\", title=\"Instrument-2\")\n >>> instrument3 = api.create(instruments, \"Instrument\", title=\"Instrument-3\")\n\n >>> results = api.search({'portal_type': 'Instrument', 'sort_on': 'getId'})\n >>> len(results)\n 3\n\n >>> map(api.get_id, results)\n ['instrument-1', 'instrument-2', 'instrument-3']\n\nQueries which result in multiple catalogs will be refused, as it would require\nmanual merging and sorting of the results afterwards. Thus, we fail here::\n\n >>> results = api.search({'portal_type': ['Client', 'ClientFolder', 'Instrument'], 'sort_on': 'getId'})\n Traceback (most recent call last):\n [...]\n SenaiteAPIError: Multi Catalog Queries are not supported, please specify a catalog.\n\nCatalog queries w/o any `portal_type`, default to the `portal_catalog`, which\nwill not find the following items::\n\n >>> analysiscategories = bika_setup.bika_analysiscategories\n >>> analysiscategory1 = api.create(analysiscategories, \"AnalysisCategory\", title=\"AC-1\")\n >>> analysiscategory2 = api.create(analysiscategories, \"AnalysisCategory\", title=\"AC-2\")\n >>> analysiscategory3 = api.create(analysiscategories, \"AnalysisCategory\", title=\"AC-3\")\n\n >>> results = api.search({\"id\": \"analysiscategory-1\"})\n >>> len(results)\n 0\n\nWould we add the `portal_type`, the search function would ask the\n`archetype_tool` for the right catalog, and it would return a result::\n\n >>> results = api.search({\"portal_type\": \"AnalysisCategory\", \"id\": \"analysiscategory-1\"})\n >>> len(results)\n 1\n\nWe could also explicitly define a catalog to achieve the same::\n\n >>> results = api.search({\"id\": \"analysiscategory-1\"}, catalog=\"bika_setup_catalog\")\n >>> len(results)\n 1\n\nTo see inactive or dormant items, we must explicitly query them of filter them\nafterwars manually::\n\n >>> results = api.search({\"portal_type\": \"AnalysisCategory\", \"id\": \"analysiscategory-1\"})\n >>> len(results)\n 1\n\nNow we deactivate the item::\n\n >>> analysiscategory1 = api.do_transition_for(analysiscategory1, 'deactivate')\n >>> api.is_active(analysiscategory1)\n False\n\nThe search will still find the item::\n\n >>> results = api.search({\"portal_type\": \"AnalysisCategory\", \"id\": \"analysiscategory-1\"})\n >>> len(results)\n 1\n\nUnless we filter it out manually::\n\n >>> len(filter(api.is_active, results))\n 0\n\nOr provide a correct query::\n\n >>> results = api.search({\"portal_type\": \"AnalysisCategory\", \"id\": \"analysiscategory-1\", \"inactive_status\": \"active\"})\n >>> len(results)\n 1\n\n\nGetting the registered Catalogs\n-------------------------------\n\nBika LIMS uses multiple catalogs registered via the Archetype Tool. This\nfunction returns a list of registered catalogs for a brain or object::\n\n >>> api.get_catalogs_for(client)\n []\n\n >>> api.get_catalogs_for(instrument1)\n [, ]\n\n >>> api.get_catalogs_for(analysiscategory1)\n []\n\n\nGetting an Attribute of an Object\n---------------------------------\n\nThis function handles attributes and methods the same and returns their value.\nIt also handles security and is able to return a default value instead of\nraising an `Unauthorized` error::\n\n >>> uid_brain = api.safe_getattr(brain, \"UID\")\n >>> uid_obj = api.safe_getattr(client, \"UID\")\n\n >>> uid_brain == uid_obj\n True\n\n >>> api.safe_getattr(brain, \"review_state\")\n 'active'\n\n >>> api.safe_getattr(brain, \"NONEXISTING\")\n Traceback (most recent call last):\n [...]\n SenaiteAPIError: Attribute 'NONEXISTING' not found.\n\n >>> api.safe_getattr(brain, \"NONEXISTING\", \"\")\n ''\n\nGetting the Portal Catalog\n--------------------------\n\nThis tool is needed so often, that this function just returns it::\n\n >>> api.get_portal_catalog()\n \n\n\nGetting the Review History of an Object\n---------------------------------------\n\nThe review history gives information about the objects' workflow changes::\n\n >>> review_history = api.get_review_history(client)\n >>> sorted(review_history[0].items())\n [('action', None), ('actor', 'test_user_1_'), ('comments', ''), ('review_state', 'active'), ('time', DateTime('...'))]\n\n\nGetting the Revision History of an Object\n-----------------------------------------\n\nThe review history gives information about the objects' workflow changes::\n\n >>> revision_history = api.get_revision_history(client)\n >>> sorted(revision_history[0])\n ['action', 'actor', 'actor_home', 'actorid', 'comments', 'review_state', 'state_title', 'time', 'transition_title', 'type']\n >>> revision_history[0][\"transition_title\"]\n u'Create'\n\n\nGetting the assigned Workflows of an Object\n-------------------------------------------\n\nThis function returns all assigned workflows for a given object::\n\n >>> api.get_workflows_for(bika_setup)\n ('bika_one_state_workflow',)\n\n >>> api.get_workflows_for(client)\n ('bika_client_workflow', 'bika_inactive_workflow')\n\nThis function also supports the portal_type as parameter::\n\n >>> api.get_workflows_for(api.get_portal_type(client))\n ('bika_client_workflow', 'bika_inactive_workflow')\n\n\nGetting the Workflow Status of an Object\n----------------------------------------\n\nThis function returns the state of a given object::\n\n >>> api.get_workflow_status_of(client)\n 'active'\n\nIt is also capable to get the state of another state variable::\n\n >>> api.get_workflow_status_of(client, \"inactive_state\")\n 'active'\n\nDeactivate the client::\n\n >>> api.do_transition_for(client, \"deactivate\")\n \n\n >>> api.get_workflow_status_of(client, \"inactive_state\")\n 'inactive'\n\n >>> api.get_workflow_status_of(client)\n 'active'\n\nReactivate the client::\n\n >>> api.do_transition_for(client, \"activate\")\n \n\n >>> api.get_workflow_status_of(client, \"inactive_state\")\n 'active'\n\n\nGetting the available transitions for an object\n-----------------------------------------------\n\nThis function returns all possible transitions from all workflows in the\nobject's workflow chain.\n\nLet's create a Batch. It should allow us to invoke transitions from two\nworkflows; 'close' from the bika_batch_workflow, and 'cancel' from the\nbika_cancellation_workflow::\n\n >>> batch1 = api.create(portal.batches, \"Batch\", title=\"Test Batch\")\n >>> transitions = api.get_transitions_for(batch1)\n >>> len(transitions)\n 2\n\nThe transitions are returned as a list of dictionaries. Since we cannot rely on\nthe order of dictionary keys, we will have to satisfy ourselves here with\nchecking that the two expected transitions are present in the return value::\n\n >>> 'Close' in [t['title'] for t in transitions]\n True\n >>> 'Cancel' in [t['title'] for t in transitions]\n True\n\n\nGetting the creation date of an object\n--------------------------------------\n\nThis function returns the creation date of a given object::\n\n >>> created = api.get_creation_date(client)\n >>> created\n DateTime('...')\n\n\nGetting the modification date of an object\n------------------------------------------\n\nThis function returns the modification date of a given object::\n\n >>> modified = api.get_modification_date(client)\n >>> modified\n DateTime('...')\n\n\nGetting the review state of an object\n-------------------------------------\n\nThis function returns the review state of a given object::\n\n >>> review_state = api.get_review_status(client)\n >>> review_state\n 'active'\n\nIt should also work for catalog brains::\n\n >>> portal_catalog = api.get_tool(\"portal_catalog\")\n >>> results = portal_catalog({\"portal_type\": \"Client\", \"UID\": api.get_uid(client)})\n >>> len(results)\n 1\n >>> api.get_review_status(results[0]) == review_state\n True\n\n\nGetting the registered Catalogs of an Object\n--------------------------------------------\n\nThis function returns a list of all registered catalogs within the\n`archetype_tool` for a given portal_type or object::\n\n >>> api.get_catalogs_for(client)\n []\n\nIt also supports the `portal_type` as a parameter::\n\n >>> api.get_catalogs_for(\"Analysis\")\n []\n\n\nTransitioning an Object\n-----------------------\n\nThis function performs a workflow transition and returns the object::\n\n >>> client = api.do_transition_for(client, \"deactivate\")\n >>> api.is_active(client)\n False\n\n >>> client = api.do_transition_for(client, \"activate\")\n >>> api.is_active(client)\n True\n\n\nGetting inactive/cancellation state of different workflows\n----------------------------------------------------------\n\nThere are two workflows allowing an object to be set inactive. We provide\nthe is_active function to return False if an item is set inactive with either\nof these workflows.\n\nIn the search() test above, the is_active function's handling of brain states\nis tested. Here, I just want to test if object states are handled correctly.\n\nFor setup types, we use bika_inctive_workflow::\n\n >>> method1 = api.create(portal.methods, \"Method\", title=\"Test Method\")\n >>> api.is_active(method1)\n True\n >>> method1 = api.do_transition_for(method1, 'deactivate')\n >>> api.is_active(method1)\n False\n\nFor transactional types, bika_cancellation_workflow is used::\n\n >>> batch1 = api.create(portal.batches, \"Batch\", title=\"Test Batch\")\n >>> api.is_active(batch1)\n True\n >>> batch1 = api.do_transition_for(batch1, 'cancel')\n >>> api.is_active(batch1)\n False\n\n\nGetting the granted Roles for a certain Permission on an Object\n---------------------------------------------------------------\n\nThis function returns a list of Roles, which are granted the given Permission\nfor the passed in object::\n\n >>> api.get_roles_for_permission(\"Modify portal content\", bika_setup)\n ['LabManager', 'Manager']\n\n\n\nChecking if an Object is Versionable\n------------------------------------\n\nSome contents in Bika LIMS support versioning. This function checks this for you.\n\nInstruments are not versionable::\n\n >>> api.is_versionable(instrument1)\n False\n\nAnalysisservices are versionable::\n\n >>> analysisservices = bika_setup.bika_analysisservices\n >>> analysisservice1 = api.create(analysisservices, \"AnalysisService\", title=\"AnalysisService-1\")\n >>> analysisservice2 = api.create(analysisservices, \"AnalysisService\", title=\"AnalysisService-2\")\n >>> analysisservice3 = api.create(analysisservices, \"AnalysisService\", title=\"AnalysisService-3\")\n\n >>> api.is_versionable(analysisservice1)\n True\n\n\nGetting the Version of an Object\n--------------------------------\n\nThis function returns the version as an integer::\n\n >>> api.get_version(analysisservice1)\n 0\n\nCalling `processForm` bumps the version::\n\n >>> analysisservice1.processForm()\n >>> api.get_version(analysisservice1)\n 1\n\n\nGetting a Browser View\n----------------------\n\nGetting a browser view is a common task in Bika LIMS::\n\n >>> api.get_view(\"plone\")\n \n\n >>> api.get_view(\"workflow_action\")\n \n\n\nGetting the Request\n-------------------\n\nThis function will return the global request object::\n\n >>> api.get_request()\n \n\n\nGetting a Group\n---------------\n\nUsers in Bika LIMS are managed in groups. A common group is the `Clients` group,\nwhere all users of client contacts are grouped.\nThis function gives easy access and is also idempotent::\n\n >>> clients_group = api.get_group(\"Clients\")\n >>> clients_group\n \n\n >>> api.get_group(clients_group)\n \n\nNon-existing groups are not found::\n\n >>> api.get_group(\"NonExistingGroup\")\n\n\nGetting a User\n--------------\n\nUsers can be fetched by their user id. The function is idempotent and handles\nuser objects as well::\n\n >>> from plone.app.testing import TEST_USER_ID\n >>> user = api.get_user(TEST_USER_ID)\n >>> user\n \n\n >>> api.get_user(api.get_user(TEST_USER_ID))\n \n\nNon-existing users are not found::\n\n >>> api.get_user(\"NonExistingUser\")\n\n\nGetting User Properties\n-----------------------\n\nUser properties, like the email or full name, are stored as user properties.\nThis means that they are not on the user object. This function retrieves these\nproperties for you::\n\n >>> properties = api.get_user_properties(TEST_USER_ID)\n >>> sorted(properties.items())\n [('description', ''), ('email', ''), ('error_log_update', 0.0), ('ext_editor', False), ...]\n\n >>> sorted(api.get_user_properties(user).items())\n [('description', ''), ('email', ''), ('error_log_update', 0.0), ('ext_editor', False), ...]\n\nAn empty property dict is returned if no user could be found::\n\n >>> api.get_user_properties(\"NonExistingUser\")\n {}\n\n >>> api.get_user_properties(None)\n {}\n\n\nGetting Users by their Roles\n----------------------------\n\n::\n\n >>> from operator import methodcaller\n\nRoles in Bika LIMS are basically a name for one or more permissions. For\nexample, a `LabManager` describes a role which is granted the most permissions.\n\nTo see which users are granted a certain role, you can use this function::\n\n >>> labmanagers = api.get_users_by_roles([\"LabManager\"])\n >>> sorted(labmanagers, key=methodcaller('getId'))\n [, , ]\n\nA single value can also be passed into this function::\n\n >>> sorted(api.get_users_by_roles(\"LabManager\"), key=methodcaller('getId'))\n [, , ]\n\n\nGetting the Current User\n------------------------\n\nGetting the current logged in user::\n\n >>> api.get_current_user()\n \n\n\nGetting the Contact associated to a Plone user\n----------------------------------------------\n\nGetting a Plone user previously registered with no contact assigned::\n\n >>> user = api.get_user('test_labmanager1')\n >>> contact = api.get_user_contact(user)\n >>> contact is None\n True\n\nAssign a new contact to this user::\n\n >>> labcontacts = bika_setup.bika_labcontacts\n >>> labcontact = api.create(labcontacts, \"LabContact\", Firstname=\"Lab\", Lastname=\"Manager\")\n >>> labcontact.setUser(user)\n True\n\nAnd get the contact associated to the user::\n\n >>> api.get_user_contact(user)\n \n\nAs well as if we specify only `LabContact` type::\n\n >>> api.get_user_contact(user, ['LabContact'])\n \n\nBut fails if we specify only `Contact` type::\n\n >>> nuser = api.get_user_contact(user, ['Contact'])\n >>> nuser is None\n True\n\n\nCreating a Cache Key\n--------------------\n\nThis function creates a good cache key for a generic object or brain::\n\n >>> key1 = api.get_cache_key(client)\n >>> key1\n 'Client-client-1-...'\n\nThis can be also done for a catalog result brain::\n\n >>> portal_catalog = api.get_tool(\"portal_catalog\")\n >>> brains = portal_catalog({\"portal_type\": \"Client\", \"UID\": api.get_uid(client)})\n >>> key2 = api.get_cache_key(brains[0])\n >>> key2\n 'Client-client-1-...'\n\nThe two keys should be equal::\n\n >>> key1 == key2\n True\n\nThe key should change when the object get modified::\n\n >>> from zope.lifecycleevent import modified\n >>> client.setClientID(\"TESTCLIENT\")\n >>> modified(client)\n >>> portal.aq_parent._p_jar.sync()\n >>> key3 = api.get_cache_key(client)\n >>> key3 != key1\n True\n\n.. important:: Workflow changes do not change the modification date!\n A custom event subscriber will update it therefore.\n\nA workflow transition should also change the cache key::\n\n >>> _ = api.do_transition_for(client, transition=\"deactivate\")\n >>> api.get_inactive_status(client)\n 'inactive'\n >>> key4 = api.get_cache_key(client)\n >>> key4 != key3\n True\n\n\nCache Key decorator\n-------------------\n\nThis decorator can be used for `plone.memoize` cache decorators in classes.\nThe decorator expects that the first argument is the class instance (`self`) and\nthe second argument a brain or object::\n\n >>> from plone.memoize.volatile import cache\n\n >>> class BikaClass(object):\n ... @cache(api.bika_cache_key_decorator)\n ... def get_very_expensive_calculation(self, obj):\n ... print \"very expensive calculation\"\n ... return \"calculation result\"\n\nCalling the (expensive) method of the class does the calculation just once::\n\n >>> instance = BikaClass()\n >>> instance.get_very_expensive_calculation(client)\n very expensive calculation\n 'calculation result'\n >>> instance.get_very_expensive_calculation(client)\n 'calculation result'\n\nThe decorator can also handle brains::\n\n >>> instance = BikaClass()\n >>> portal_catalog = api.get_tool(\"portal_catalog\")\n >>> brain = portal_catalog(portal_type=\"Client\")[0]\n >>> instance.get_very_expensive_calculation(brain)\n very expensive calculation\n 'calculation result'\n >>> instance.get_very_expensive_calculation(brain)\n 'calculation result'\n\n\nID Normalizer\n-------------\n\nNormalizes a string to be usable as a system ID::\n\n >>> api.normalize_id(\"My new ID\")\n 'my-new-id'\n\n >>> api.normalize_id(\"Really/Weird:Name;\")\n 'really-weird-name'\n\n >>> api.normalize_id(None)\n Traceback (most recent call last):\n [...]\n SenaiteAPIError: Type of argument must be string, found ''\n\n\nFile Normalizer\n---------------\n\nNormalizes a string to be usable as a file name::\n\n >>> api.normalize_filename(\"My new ID\")\n 'My new ID'\n\n >>> api.normalize_filename(\"Really/Weird:Name;\")\n 'Really-Weird-Name'\n\n >>> api.normalize_filename(None)\n Traceback (most recent call last):\n [...]\n SenaiteAPIError: Type of argument must be string, found ''\n\n\nCheck if an UID is valid\n------------------------\n\nChecks if an UID is a valid 23 alphanumeric uid::\n\n >>> api.is_uid(\"ajw2uw9\")\n False\n\n >>> api.is_uid(None)\n False\n\n >>> api.is_uid(\"\")\n False\n\n >>> api.is_uid(\"0\")\n False\n\n >>> api.is_uid('0e1dfc3d10d747bf999948a071bc161e')\n True\n\nChecks if an UID is a valid 23 alphanumeric uid and with a brain::\n\n >>> api.is_uid(\"ajw2uw9\", validate=True)\n False\n\n >>> api.is_uid(None, validate=True)\n False\n\n >>> api.is_uid(\"\", validate=True)\n False\n\n >>> api.is_uid(\"0\", validate=True)\n False\n\n >>> api.is_uid('0e1dfc3d10d747bf999948a071bc161e', validate=True)\n False\n\n >>> asfolder = self.portal.bika_setup.bika_analysisservices\n >>> serv = api.create(asfolder, \"AnalysisService\", title=\"AS test\")\n >>> serv.setKeyword(\"as_test\")\n >>> uid = serv.UID()\n >>> api.is_uid(uid, validate=True)\n True\n\n\nCheck if a Date is valid\n------------------------\n\nDo some imports first::\n\n >>> from datetime import datetime\n >>> from DateTime import DateTime\n\nChecks if a DateTime is valid::\n\n >>> now = DateTime()\n >>> api.is_date(now)\n True\n\n >>> now = datetime.now()\n >>> api.is_date(now)\n True\n\n >>> now = DateTime(now)\n >>> api.is_date(now)\n True\n\n >>> api.is_date(None)\n False\n\n >>> api.is_date('2018-04-23')\n False\n\n\nTry conversions to Date\n-----------------------\n\nTry to convert to DateTime::\n\n >>> now = DateTime()\n >>> zpdt = api.to_date(now)\n >>> zpdt.ISO8601() == now.ISO8601()\n True\n\n >>> now = datetime.now()\n >>> zpdt = api.to_date(now)\n >>> pydt = zpdt.asdatetime()\n\nNote that here, for the comparison between dates, we convert DateTime to python\ndatetime, cause DateTime.strftime() is broken for timezones (always looks at\nsystem time zone, ignores the timezone and offset of the DateTime instance\nitself)::\n\n >>> pydt.strftime('%Y-%m-%dT%H:%M:%S') == now.strftime('%Y-%m-%dT%H:%M:%S')\n True\n\nTry the same, but with utcnow() instead::\n\n >>> now = datetime.utcnow()\n >>> zpdt = api.to_date(now)\n >>> pydt = zpdt.asdatetime()\n >>> pydt.strftime('%Y-%m-%dT%H:%M:%S') == now.strftime('%Y-%m-%dT%H:%M:%S')\n True\n\nNow we convert just a string formatted date::\n\n >>> strd = \"2018-12-01 17:50:34\"\n >>> zpdt = api.to_date(strd)\n >>> zpdt.ISO8601()\n '2018-12-01T17:50:34'\n\nNow we convert just a string formatted date, but with timezone::\n\n >>> strd = \"2018-12-01 17:50:34 GMT+1\"\n >>> zpdt = api.to_date(strd)\n >>> zpdt.ISO8601()\n '2018-12-01T17:50:34+01:00'\n\nWe also check a bad date here (note the month is 13)::\n\n >>> strd = \"2018-13-01 17:50:34\"\n >>> zpdt = api.to_date(strd)\n >>> api.is_date(zpdt)\n False\n\nAnd with European format::\n\n >>> strd = \"01.12.2018 17:50:34\"\n >>> zpdt = api.to_date(strd)\n >>> zpdt.ISO8601()\n '2018-12-01T17:50:34'\n\n >>> zpdt = api.to_date(None)\n >>> zpdt is None\n True\n\nUse a string formatted date as fallback::\n\n >>> strd = \"2018-13-01 17:50:34\"\n >>> default_date = \"2018-01-01 19:30:30\"\n >>> zpdt = api.to_date(strd, default_date)\n >>> zpdt.ISO8601()\n '2018-01-01T19:30:30'\n\nUse a DateTime object as fallback::\n\n >>> strd = \"2018-13-01 17:50:34\"\n >>> default_date = \"2018-01-01 19:30:30\"\n >>> default_date = api.to_date(default_date)\n >>> zpdt = api.to_date(strd, default_date)\n >>> zpdt.ISO8601() == default_date.ISO8601()\n True\n\nUse a datetime object as fallback::\n\n >>> strd = \"2018-13-01 17:50:34\"\n >>> default_date = datetime.now()\n >>> zpdt = api.to_date(strd, default_date)\n >>> dzpdt = api.to_date(default_date)\n >>> zpdt.ISO8601() == dzpdt.ISO8601()\n True\n\nUse a non-conversionable value as fallback::\n\n >>> strd = \"2018-13-01 17:50:34\"\n >>> default_date = \"something wrong here\"\n >>> zpdt = api.to_date(strd, default_date)\n >>> zpdt is None\n True\n\n\nCheck if floatable\n------------------\n\n::\n\n >>> api.is_floatable(None)\n False\n\n >>> api.is_floatable(\"\")\n False\n\n >>> api.is_floatable(\"31\")\n True\n\n >>> api.is_floatable(\"31.23\")\n True\n\n >>> api.is_floatable(\"-13\")\n True\n\n >>> api.is_floatable(\"12,35\")\n False\n\n\nConvert to a float number\n-------------------------\n\n::\n\n >>> api.to_float(\"2\")\n 2.0\n\n >>> api.to_float(\"2.234\")\n 2.234\n\nWith default fallback::\n\n >>> api.to_float(None, 2)\n 2.0\n\n >>> api.to_float(None, \"2\")\n 2.0\n\n >>> api.to_float(\"\", 2)\n 2.0\n\n >>> api.to_float(\"\", \"2\")\n 2.0\n\n >>> api.to_float(2.1, 2)\n 2.1\n\n >>> api.to_float(\"2.1\", 2)\n 2.1\n\n >>> api.to_float(\"2.1\", \"2\")\n 2.1\n\n\nAPI Analysis\n============\n\nThe api_analysis provides single functions for single purposes especifically\nrelated with analyses.\n\nRunning this test from the buildout directory::\n\n bin/test test_textual_doctests -t API_analysis\n\n\nTest Setup\n----------\n\nNeeded Imports::\n\n >>> import re\n >>> from AccessControl.PermissionRole import rolesForPermissionOn\n >>> from bika.lims import api\n >>> from bika.lims.api.analysis import is_out_of_range\n >>> from bika.lims.content.analysisrequest import AnalysisRequest\n >>> from bika.lims.content.sample import Sample\n >>> from bika.lims.content.samplepartition import SamplePartition\n >>> from bika.lims.utils.analysisrequest import create_analysisrequest\n >>> from bika.lims.utils.sample import create_sample\n >>> from bika.lims.utils import tmpID\n >>> from bika.lims.workflow import doActionFor\n >>> from bika.lims.workflow import getCurrentState\n >>> from bika.lims.workflow import getAllowedTransitions\n >>> from DateTime import DateTime\n >>> from plone.app.testing import TEST_USER_ID\n >>> from plone.app.testing import TEST_USER_PASSWORD\n >>> from plone.app.testing import setRoles\n\nFunctional Helpers::\n\n >>> def start_server():\n ... from Testing.ZopeTestCase.utils import startZServer\n ... ip, port = startZServer()\n ... return \"http://{}:{}/{}\".format(ip, port, portal.id)\n\nVariables::\n\n >>> portal = self.portal\n >>> request = self.request\n >>> bikasetup = portal.bika_setup\n\nWe need to create some basic objects for the test::\n\n >>> setRoles(portal, TEST_USER_ID, ['LabManager',])\n >>> date_now = DateTime().strftime(\"%Y-%m-%d\")\n >>> date_future = (DateTime() + 5).strftime(\"%Y-%m-%d\")\n >>> client = api.create(portal.clients, \"Client\", Name=\"Happy Hills\", ClientID=\"HH\", MemberDiscountApplies=True)\n >>> contact = api.create(client, \"Contact\", Firstname=\"Rita\", Lastname=\"Mohale\")\n >>> sampletype = api.create(bikasetup.bika_sampletypes, \"SampleType\", title=\"Water\", Prefix=\"W\")\n >>> labcontact = api.create(bikasetup.bika_labcontacts, \"LabContact\", Firstname=\"Lab\", Lastname=\"Manager\")\n >>> department = api.create(bikasetup.bika_departments, \"Department\", title=\"Chemistry\", Manager=labcontact)\n >>> category = api.create(bikasetup.bika_analysiscategories, \"AnalysisCategory\", title=\"Metals\", Department=department)\n >>> supplier = api.create(bikasetup.bika_suppliers, \"Supplier\", Name=\"Naralabs\")\n >>> Cu = api.create(bikasetup.bika_analysisservices, \"AnalysisService\", title=\"Copper\", Keyword=\"Cu\", Price=\"15\", Category=category.UID(), DuplicateVariation=\"0.5\")\n >>> Fe = api.create(bikasetup.bika_analysisservices, \"AnalysisService\", title=\"Iron\", Keyword=\"Fe\", Price=\"10\", Category=category.UID(), DuplicateVariation=\"0.5\")\n >>> Au = api.create(bikasetup.bika_analysisservices, \"AnalysisService\", title=\"Gold\", Keyword=\"Au\", Price=\"20\", Category=category.UID(), DuplicateVariation=\"0.5\")\n >>> Mg = api.create(bikasetup.bika_analysisservices, \"AnalysisService\", title=\"Magnesium\", Keyword=\"Mg\", Price=\"20\", Category=category.UID(), DuplicateVariation=\"0.5\")\n >>> service_uids = [api.get_uid(an) for an in [Cu, Fe, Au, Mg]]\n\nCreate an Analysis Specification for `Water`::\n\n >>> sampletype_uid = api.get_uid(sampletype)\n >>> rr1 = {\"keyword\": \"Au\", \"min\": \"-5\", \"max\": \"5\", \"warn_min\": \"-5.5\", \"warn_max\": \"5.5\"}\n >>> rr2 = {\"keyword\": \"Cu\", \"min\": \"10\", \"max\": \"20\", \"warn_min\": \"9.5\", \"warn_max\": \"20.5\"}\n >>> rr3 = {\"keyword\": \"Fe\", \"min\": \"0\", \"max\": \"10\", \"warn_min\": \"-0.5\", \"warn_max\": \"10.5\"}\n >>> rr4 = {\"keyword\": \"Mg\", \"min\": \"10\", \"max\": \"10\"}\n >>> rr = [rr1, rr2, rr3, rr4]\n >>> specification = api.create(bikasetup.bika_analysisspecs, \"AnalysisSpec\", title=\"Lab Water Spec\", SampleType=sampletype_uid, ResultsRange=rr)\n >>> spec_uid = api.get_uid(specification)\n\nCreate a Reference Definition for blank::\n\n >>> blankdef = api.create(bikasetup.bika_referencedefinitions, \"ReferenceDefinition\", title=\"Blank definition\", Blank=True)\n >>> blank_refs = [{'uid': Au.UID(), 'result': '0', 'min': '0', 'max': '0'},]\n >>> blankdef.setReferenceResults(blank_refs)\n\nAnd for control::\n\n >>> controldef = api.create(bikasetup.bika_referencedefinitions, \"ReferenceDefinition\", title=\"Control definition\")\n >>> control_refs = [{'uid': Au.UID(), 'result': '10', 'min': '9.99', 'max': '10.01'},\n ... {'uid': Cu.UID(), 'result': '-0.9','min': '-1.08', 'max': '-0.72'},]\n >>> controldef.setReferenceResults(control_refs)\n\n >>> blank = api.create(supplier, \"ReferenceSample\", title=\"Blank\",\n ... ReferenceDefinition=blankdef,\n ... Blank=True, ExpiryDate=date_future,\n ... ReferenceResults=blank_refs)\n >>> control = api.create(supplier, \"ReferenceSample\", title=\"Control\",\n ... ReferenceDefinition=controldef,\n ... Blank=False, ExpiryDate=date_future,\n ... ReferenceResults=control_refs)\n\nCreate an Analysis Request::\n\n >>> values = {\n ... 'Client': api.get_uid(client),\n ... 'Contact': api.get_uid(contact),\n ... 'DateSampled': date_now,\n ... 'SampleType': sampletype_uid,\n ... 'Specification': spec_uid,\n ... 'Priority': '1',\n ... }\n\n >>> ar = create_analysisrequest(client, request, values, service_uids)\n >>> success = doActionFor(ar, 'receive')\n\nCreate a new Worksheet and add the analyses::\n\n >>> worksheet = api.create(portal.worksheets, \"Worksheet\")\n >>> analyses = map(api.get_object, ar.getAnalyses())\n >>> for analysis in analyses:\n ... worksheet.addAnalysis(analysis)\n\nAdd a duplicate for `Cu`::\n\n >>> position = worksheet.get_slot_position(ar, 'a')\n >>> duplicates = worksheet.addDuplicateAnalyses(position)\n >>> duplicates.sort(key=lambda analysis: analysis.getKeyword(), reverse=False)\n\nAdd a blank and a control::\n\n >>> blanks = worksheet.addReferenceAnalyses(blank, service_uids)\n >>> blanks.sort(key=lambda analysis: analysis.getKeyword(), reverse=False)\n >>> controls = worksheet.addReferenceAnalyses(control, service_uids)\n >>> controls.sort(key=lambda analysis: analysis.getKeyword(), reverse=False)\n\n\nCheck if results are out of range\n---------------------------------\n\nFirst, get the analyses from slot 1 and sort them asc::\n\n >>> analyses = worksheet.get_analyses_at(1)\n >>> analyses.sort(key=lambda analysis: analysis.getKeyword(), reverse=False)\n\nSet results for analysis `Au` (min: -5, max: 5, warn_min: -5.5, warn_max: 5.5)::\n\n >>> au_analysis = analyses[0]\n >>> au_analysis.setResult(2)\n >>> is_out_of_range(au_analysis)\n (False, False)\n\n >>> au_analysis.setResult(-2)\n >>> is_out_of_range(au_analysis)\n (False, False)\n\n >>> au_analysis.setResult(-5)\n >>> is_out_of_range(au_analysis)\n (False, False)\n\n >>> au_analysis.setResult(5)\n >>> is_out_of_range(au_analysis)\n (False, False)\n\n >>> au_analysis.setResult(10)\n >>> is_out_of_range(au_analysis)\n (True, True)\n\n >>> au_analysis.setResult(-10)\n >>> is_out_of_range(au_analysis)\n (True, True)\n\nResults in shoulders?::\n\n >>> au_analysis.setResult(-5.2)\n >>> is_out_of_range(au_analysis)\n (True, False)\n\n >>> au_analysis.setResult(-5.5)\n >>> is_out_of_range(au_analysis)\n (True, False)\n\n >>> au_analysis.setResult(-5.6)\n >>> is_out_of_range(au_analysis)\n (True, True)\n\n >>> au_analysis.setResult(5.2)\n >>> is_out_of_range(au_analysis)\n (True, False)\n\n >>> au_analysis.setResult(5.5)\n >>> is_out_of_range(au_analysis)\n (True, False)\n\n >>> au_analysis.setResult(5.6)\n >>> is_out_of_range(au_analysis)\n (True, True)\n\n\nCheck if results for duplicates are out of range\n------------------------------------------------\n\nGet the first duplicate analysis that comes from `Au`::\n\n >>> duplicate = duplicates[0]\n\nA Duplicate will be considered out of range if its result does not match with\nthe result set to the analysis that was duplicated from, with the Duplicate\nVariation in % as the margin error. The Duplicate Variation assigned in the\nAnalysis Service `Au` is 0.5%::\n\n >>> dup_variation = au_analysis.getDuplicateVariation()\n >>> dup_variation = api.to_float(dup_variation)\n >>> dup_variation\n 0.5\n\nSet an in-range result (between -5 and 5) for routine analysis and check all\nvariants on it's duplicate. Given that the duplicate variation is 0.5, the\nvalid range for the duplicate must be `Au +-0.5%`::\n\n >>> result = 2.0\n >>> au_analysis.setResult(result)\n >>> is_out_of_range(au_analysis)\n (False, False)\n\n >>> duplicate.setResult(result)\n >>> is_out_of_range(duplicate)\n (False, False)\n\n >>> dup_min_range = result - (result*(dup_variation/100))\n >>> duplicate.setResult(dup_min_range)\n >>> is_out_of_range(duplicate)\n (False, False)\n\n >>> duplicate.setResult(dup_min_range - 0.5)\n >>> is_out_of_range(duplicate)\n (True, True)\n\n >>> dup_max_range = result + (result*(dup_variation/100))\n >>> duplicate.setResult(dup_max_range)\n >>> is_out_of_range(duplicate)\n (False, False)\n\n >>> duplicate.setResult(dup_max_range + 0.5)\n >>> is_out_of_range(duplicate)\n (True, True)\n\nSet an out-of-range result, but within shoulders, for routine analysis and check\nall variants on it's duplicate. Given that the duplicate variation is 0.5, the\nvalid range for the duplicate must be `Au +-0.5%`::\n\n >>> result = 5.5\n >>> au_analysis.setResult(result)\n >>> is_out_of_range(au_analysis)\n (True, False)\n\n >>> duplicate.setResult(result)\n >>> is_out_of_range(duplicate)\n (False, False)\n\n >>> dup_min_range = result - (result*(dup_variation/100))\n >>> duplicate.setResult(dup_min_range)\n >>> is_out_of_range(duplicate)\n (False, False)\n\n >>> duplicate.setResult(dup_min_range - 0.5)\n >>> is_out_of_range(duplicate)\n (True, True)\n\n >>> dup_max_range = result + (result*(dup_variation/100))\n >>> duplicate.setResult(dup_max_range)\n >>> is_out_of_range(duplicate)\n (False, False)\n\n >>> duplicate.setResult(dup_max_range + 0.5)\n >>> is_out_of_range(duplicate)\n (True, True)\n\nSet an out-of-range and out-of-shoulders result, for routine analysis and check\nall variants on it's duplicate. Given that the duplicate variation is 0.5, the\nvalid range for the duplicate must be `Au +-0.5%`::\n\n >>> result = -7.0\n >>> au_analysis.setResult(result)\n >>> is_out_of_range(au_analysis)\n (True, True)\n\n >>> duplicate.setResult(result)\n >>> is_out_of_range(duplicate)\n (False, False)\n\n >>> dup_min_range = result - (abs(result)*(dup_variation/100))\n >>> duplicate.setResult(dup_min_range)\n >>> is_out_of_range(duplicate)\n (False, False)\n\n >>> duplicate.setResult(dup_min_range - 0.5)\n >>> is_out_of_range(duplicate)\n (True, True)\n\n >>> dup_max_range = result + (abs(result)*(dup_variation/100))\n >>> duplicate.setResult(dup_max_range)\n >>> is_out_of_range(duplicate)\n (False, False)\n\n >>> duplicate.setResult(dup_max_range + 0.5)\n >>> is_out_of_range(duplicate)\n (True, True)\n\n\nCheck if results for Reference Analyses (blanks + controls) are out of range\n----------------------------------------------------------------------------\n\nReference Analyses (controls and blanks) do not use the result ranges defined in\nthe specifications, rather they use the result range defined in the Reference\nSample they have been generated from. In turn, the result ranges defined in\nReference Samples can be set manually or acquired from the Reference Definition\nthey might be associated with. Another difference from routine analyses is that\nreference analyses don't expect a valid range, rather a discrete value, so\nshoulders are built based on % error.\n\nBlank Analyses\n..............\n\nThe first blank analysis corresponds to `Au`::\n\n >>> au_blank = blanks[0]\n\nFor `Au` blank, as per the reference definition used above, the expected result\nis 0 +/- 0.1%. Since the expected result is 0, no shoulders will be considered\nregardless of the % of error. Thus, result will always be \"out-of-shoulders\"\nwhen out of range::\n\n >>> au_blank.setResult(0.0)\n >>> is_out_of_range(au_blank)\n (False, False)\n\n >>> au_blank.setResult(\"0\")\n >>> is_out_of_range(au_blank)\n (False, False)\n\n >>> au_blank.setResult(0.0001)\n >>> is_out_of_range(au_blank)\n (True, True)\n\n >>> au_blank.setResult(\"0.0001\")\n >>> is_out_of_range(au_blank)\n (True, True)\n\n >>> au_blank.setResult(-0.0001)\n >>> is_out_of_range(au_blank)\n (True, True)\n\n >>> au_blank.setResult(\"-0.0001\")\n >>> is_out_of_range(au_blank)\n (True, True)\n\nControl Analyses\n................\n\nThe first control analysis corresponds to `Au`::\n\n >>> au_control = controls[0]\n\nFor `Au` control, as per the reference definition used above, the expected\nresult is 10 +/- 0.1% = 10 +/- 0.01\n\nFirst, check for in-range values::\n\n >>> au_control.setResult(10)\n >>> is_out_of_range(au_control)\n (False, False)\n\n >>> au_control.setResult(10.0)\n >>> is_out_of_range(au_control)\n (False, False)\n\n >>> au_control.setResult(\"10\")\n >>> is_out_of_range(au_control)\n (False, False)\n\n >>> au_control.setResult(\"10.0\")\n >>> is_out_of_range(au_control)\n (False, False)\n\n >>> au_control.setResult(9.995)\n >>> is_out_of_range(au_control)\n (False, False)\n\n >>> au_control.setResult(\"9.995\")\n >>> is_out_of_range(au_control)\n (False, False)\n\n >>> au_control.setResult(10.005)\n >>> is_out_of_range(au_control)\n (False, False)\n\n >>> au_control.setResult(\"10.005\")\n >>> is_out_of_range(au_control)\n (False, False)\n\n >>> au_control.setResult(9.99)\n >>> is_out_of_range(au_control)\n (False, False)\n\n >>> au_control.setResult(\"9.99\")\n >>> is_out_of_range(au_control)\n (False, False)\n\n >>> au_control.setResult(10.01)\n >>> is_out_of_range(au_control)\n (False, False)\n\n >>> au_control.setResult(\"10.01\")\n >>> is_out_of_range(au_control)\n (False, False)\n\nNow, check for out-of-range results::\n\n >>> au_control.setResult(9.98)\n >>> is_out_of_range(au_control)\n (True, True)\n\n >>> au_control.setResult(\"9.98\")\n >>> is_out_of_range(au_control)\n (True, True)\n\n >>> au_control.setResult(10.011)\n >>> is_out_of_range(au_control)\n (True, True)\n\n >>> au_control.setResult(\"10.011\")\n >>> is_out_of_range(au_control)\n (True, True)\n\nAnd do the same with the control for `Cu` that expects -0.9 +/- 20%::\n\n >>> cu_control = controls[1]\n\nFirst, check for in-range values::\n\n >>> cu_control.setResult(-0.9)\n >>> is_out_of_range(cu_control)\n (False, False)\n\n >>> cu_control.setResult(\"-0.9\")\n >>> is_out_of_range(cu_control)\n (False, False)\n\n >>> cu_control.setResult(-1.08)\n >>> is_out_of_range(cu_control)\n (False, False)\n\n >>> cu_control.setResult(\"-1.08\")\n >>> is_out_of_range(cu_control)\n (False, False)\n\n >>> cu_control.setResult(-1.07)\n >>> is_out_of_range(cu_control)\n (False, False)\n\n >>> cu_control.setResult(\"-1.07\")\n >>> is_out_of_range(cu_control)\n (False, False)\n\n >>> cu_control.setResult(-0.72)\n >>> is_out_of_range(cu_control)\n (False, False)\n\n >>> cu_control.setResult(\"-0.72\")\n >>> is_out_of_range(cu_control)\n (False, False)\n\n >>> cu_control.setResult(-0.73)\n >>> is_out_of_range(cu_control)\n (False, False)\n\n >>> cu_control.setResult(\"-0.73\")\n >>> is_out_of_range(cu_control)\n (False, False)\n\nNow, check for out-of-range results::\n\n >>> cu_control.setResult(0)\n >>> is_out_of_range(cu_control)\n (True, True)\n\n >>> cu_control.setResult(\"0\")\n >>> is_out_of_range(cu_control)\n (True, True)\n\n >>> cu_control.setResult(-0.71)\n >>> is_out_of_range(cu_control)\n (True, True)\n\n >>> cu_control.setResult(\"-0.71\")\n >>> is_out_of_range(cu_control)\n (True, True)\n\n >>> cu_control.setResult(-1.09)\n >>> is_out_of_range(cu_control)\n (True, True)\n\n >>> cu_control.setResult(\"-1.09\")\n >>> is_out_of_range(cu_control)\n (True, True)\n\n\nChangelog\n=========\n1.2.3 (2018-06-23)\n------------------\n\n- More PyPI fixtures\n\n\n1.2.2 (2018-06-23)\n------------------\n\n- PyPI Documentation Page fixtures\n\n\n1.2.1 (2018-06-23)\n------------------\n\n- Better Documentation Page for PyPI\n- Fixed formatting of Doctests\n\n\n1.2.0 (2018-06-23)\n------------------\n\n**Added**\n\n- Added `is_uid` function\n\n**Removed**\n\n**Changed**\n\n- Added SENAITE CORE API functions\n\n**Fixed**\n\n- Fixed Tests\n\n**Security**\n\n\n1.1.0 (2018-01-03)\n------------------\n\n**Added**\n\n**Removed**\n\n**Changed**\n\n- License changed to GPLv2\n- Integration to SENAITE CORE\n\n**Fixed**\n\n- Fixed Tests\n\n**Security**\n\n\n1.0.2 (2017-11-24)\n------------------\n\n- #397(bika.lims) Fix Issue-396: AttributeError: uid_catalog on AR publication\n\n\n1.0.1 (2017-09-30)\n------------------\n\n- Fixed broken release (missing MANIFEST.in)\n\n\n1.0.0 (2017-09-30)\n------------------\n\n- First release\n\n\n", "description_content_type": "", "docs_url": null, "download_url": "", "downloads": { "last_day": -1, "last_month": -1, "last_week": -1 }, "home_page": "https://github.com/senaite/senaite.api", "keywords": "", "license": "GPLv2", "maintainer": "", "maintainer_email": "", "name": "senaite.api", "package_url": "https://pypi.org/project/senaite.api/", "platform": "", "project_url": "https://pypi.org/project/senaite.api/", "project_urls": { "Homepage": "https://github.com/senaite/senaite.api" }, "release_url": "https://pypi.org/project/senaite.api/1.2.3.post2/", "requires_dist": [ "setuptools", "plone.api", "senaite.core", "Products.PloneTestCase; extra == 'test'", "Products.SecureMailHost; extra == 'test'", "plone.app.robotframework; extra == 'test'", "plone.app.testing; extra == 'test'", "robotframework-debuglibrary; extra == 'test'", "robotframework-selenium2library; extra == 'test'", "robotsuite; extra == 'test'", "unittest2; extra == 'test'" ], "requires_python": "", "summary": "SENAITE API", "version": "1.2.3.post2" }, "last_serial": 3993356, "releases": { "1.0.1": [ { "comment_text": "", "digests": { "md5": "f5e7e6818623154c7b0b15a9a701b730", "sha256": "47665ac3ab08f49c20552ddbc13c7cc48122d3b40c3387a781b7a78da8ccd47b" }, "downloads": -1, "filename": "senaite.api-1.0.1.zip", "has_sig": false, "md5_digest": "f5e7e6818623154c7b0b15a9a701b730", "packagetype": "sdist", "python_version": "source", "requires_python": null, "size": 45635, "upload_time": "2017-09-30T17:50:58", "url": "https://files.pythonhosted.org/packages/bf/a5/20dca784a34d99aafb2cb2379c7fc45e262e71000fe296ab5838a1d89335/senaite.api-1.0.1.zip" } ], "1.1.0": [ { "comment_text": "", "digests": { "md5": "1811eb9d8ad23decd095f4e7d4405edd", "sha256": "951bf0b1a6084c8c56361f8323dc049910a8d5a596f4f16d0880ffef6a0ba451" }, "downloads": -1, "filename": "senaite.api-1.1.0.zip", "has_sig": false, "md5_digest": "1811eb9d8ad23decd095f4e7d4405edd", "packagetype": "sdist", "python_version": "source", "requires_python": null, "size": 41286, "upload_time": "2018-01-03T17:01:05", "url": "https://files.pythonhosted.org/packages/b3/8b/66fe1e14b9d7f523cc4038d104e7ee6c536cccbcf470f6ad2d9f011825b2/senaite.api-1.1.0.zip" } ], "1.2.0": [ { "comment_text": "", "digests": { "md5": "fd79949a3daad285e08f2df9289ada68", "sha256": "1311a05929ba01fae98fa0d4af69eff5e63f78a7627f3c7f7c3a1b0313a7be7e" }, "downloads": -1, "filename": "senaite.api-1.2.0-py2-none-any.whl", "has_sig": false, "md5_digest": "fd79949a3daad285e08f2df9289ada68", "packagetype": "bdist_wheel", "python_version": "py2", "requires_python": null, "size": 36476, "upload_time": "2018-06-23T13:17:15", "url": "https://files.pythonhosted.org/packages/7b/b2/a84d1b7c16cd73a41d235810d4ff50db51928b94e3d620e622dd397704f2/senaite.api-1.2.0-py2-none-any.whl" }, { "comment_text": "", "digests": { "md5": "858a74079956ea9bfd83bb842236c6a6", "sha256": "a01d871db0123a873f00db747643d9595342b83b183fb18547041f279875edf9" }, "downloads": -1, "filename": "senaite.api-1.2.0.zip", "has_sig": false, "md5_digest": "858a74079956ea9bfd83bb842236c6a6", "packagetype": "sdist", "python_version": "source", "requires_python": null, "size": 55668, "upload_time": "2018-06-23T13:17:17", "url": "https://files.pythonhosted.org/packages/48/b7/672c0bdfe7f8369ea1377bdf47d5103cf836646275f652821dd453c43014/senaite.api-1.2.0.zip" } ], "1.2.0.post1": [ { "comment_text": "", "digests": { "md5": "cb9683455d2aa67c5a22f2cfd213440b", "sha256": "171aa0c2a5e093714350d1e39f211c07036e1c9f696075e339d6e190465b1880" }, "downloads": -1, "filename": "senaite.api-1.2.0.post1-py2-none-any.whl", "has_sig": false, "md5_digest": "cb9683455d2aa67c5a22f2cfd213440b", "packagetype": "bdist_wheel", "python_version": "py2", "requires_python": null, "size": 36577, "upload_time": "2018-06-23T13:32:21", "url": "https://files.pythonhosted.org/packages/63/8c/a6cc6ff265523a8caf43bfab2513f50eaeab52e78a0cee4462609349361d/senaite.api-1.2.0.post1-py2-none-any.whl" }, { "comment_text": "", "digests": { "md5": "4edadf94cb3e33d03cd6a4d48fe26cb7", "sha256": "076302d9d0a0b44900474a7f8cd7a8957fc941652b370844e85d56cb1380d4fc" }, "downloads": -1, "filename": "senaite.api-1.2.0.post1.zip", "has_sig": false, "md5_digest": "4edadf94cb3e33d03cd6a4d48fe26cb7", "packagetype": "sdist", "python_version": "source", "requires_python": null, "size": 56036, "upload_time": "2018-06-23T13:32:23", "url": "https://files.pythonhosted.org/packages/02/ba/cc40c10b98f313935704c0413a64c8bf3cd766827fc5d88ede7f590e6430/senaite.api-1.2.0.post1.zip" } ], "1.2.1": [ { "comment_text": "", "digests": { "md5": "8a0787bd843fc2cc14d442204289952e", "sha256": "e0269e2274a76d7e6fdad7605a73bb6b74f75c8ef5a2aad35da78cd41b58aafb" }, "downloads": -1, "filename": "senaite.api-1.2.1-py2-none-any.whl", "has_sig": false, "md5_digest": "8a0787bd843fc2cc14d442204289952e", "packagetype": "bdist_wheel", "python_version": "py2", "requires_python": null, "size": 39421, "upload_time": "2018-06-23T17:43:13", "url": "https://files.pythonhosted.org/packages/62/65/d9c63897ff0f436e232da2612e6d04c3d07422dcf39cbd0748a0f0d33bad/senaite.api-1.2.1-py2-none-any.whl" }, { "comment_text": "", "digests": { "md5": "7b5950f16f6b6e8c68c6a12bd6051c8f", "sha256": "f364067d8fef2c14251cf2201c72cef96ba571ba1ef48d9bd5d0f3dfb78f0471" }, "downloads": -1, "filename": "senaite.api-1.2.1.zip", "has_sig": false, "md5_digest": "7b5950f16f6b6e8c68c6a12bd6051c8f", "packagetype": "sdist", "python_version": "source", "requires_python": null, "size": 62189, "upload_time": "2018-06-23T17:43:15", "url": "https://files.pythonhosted.org/packages/c0/75/3bbf8b3e15023867294c2a699749bb62222877df9be78d805b73b1ce9e0f/senaite.api-1.2.1.zip" } ], "1.2.2": [ { "comment_text": "", "digests": { "md5": "6171d3a06732f8d6b384621805fcb336", "sha256": "d8e4125328b904704a7f5265ed6bac971a46d02ab13ad0a3cfffe189c52661ac" }, "downloads": -1, "filename": "senaite.api-1.2.2-py2-none-any.whl", "has_sig": false, "md5_digest": "6171d3a06732f8d6b384621805fcb336", "packagetype": "bdist_wheel", "python_version": "py2", "requires_python": null, "size": 40289, "upload_time": "2018-06-23T17:58:03", "url": "https://files.pythonhosted.org/packages/fd/df/b813d171de415be51cde01c897c6d6a6a60d9c3576ae0c6fe7ea80e1726d/senaite.api-1.2.2-py2-none-any.whl" }, { "comment_text": "", "digests": { "md5": "d6079e2513e069ab1c5dc0171f12a18a", "sha256": "bbd8576177835e64d6946124e02bf6ee4c3230aeb64776e47fc28d8784f12049" }, "downloads": -1, "filename": "senaite.api-1.2.2.zip", "has_sig": false, "md5_digest": "d6079e2513e069ab1c5dc0171f12a18a", "packagetype": "sdist", "python_version": "source", "requires_python": null, "size": 64927, "upload_time": "2018-06-23T17:58:05", "url": "https://files.pythonhosted.org/packages/a7/46/f034c646dcb7eb0248e993c5736cfc3dfdb439835c40fed341d2934f427f/senaite.api-1.2.2.zip" } ], "1.2.3": [ { "comment_text": "", "digests": { "md5": "7c4724580884d457ce3ba2c51c2d9f1b", "sha256": "1bbdb0843530f3c13648edbfc19d671e9a39a1d8ae6e421d03d2684a59bc4792" }, "downloads": -1, "filename": "senaite.api-1.2.3-py2-none-any.whl", "has_sig": false, "md5_digest": "7c4724580884d457ce3ba2c51c2d9f1b", "packagetype": "bdist_wheel", "python_version": "py2", "requires_python": null, "size": 40306, "upload_time": "2018-06-23T18:00:09", "url": "https://files.pythonhosted.org/packages/5f/ff/89bc491d551ece6b629b484378e52bfad4d0a5f4e614c3ab229b1119866b/senaite.api-1.2.3-py2-none-any.whl" }, { "comment_text": "", "digests": { "md5": "62eedfd54c61d04fda484b6f7680a557", "sha256": "10c789f0c55f9f1160c1fec9e12c2d37c585ba842697a6583f4d0ee69ae1c7b3" }, "downloads": -1, "filename": "senaite.api-1.2.3.zip", "has_sig": false, "md5_digest": "62eedfd54c61d04fda484b6f7680a557", "packagetype": "sdist", "python_version": "source", "requires_python": null, "size": 64976, "upload_time": "2018-06-23T18:00:10", "url": "https://files.pythonhosted.org/packages/bd/49/df41a01242651388a64d65dbd4dfaecbd644d15491987524dfe54a2eb1dc/senaite.api-1.2.3.zip" } ], "1.2.3.post1": [ { "comment_text": "", "digests": { "md5": "54ef9d1b7ce9719085451d6ee296f276", "sha256": "26787b31b87f4546433fa373fcabdfd24593a056e38b8d1ab253516d21eeb12b" }, "downloads": -1, "filename": "senaite.api-1.2.3.post1-py2-none-any.whl", "has_sig": false, "md5_digest": "54ef9d1b7ce9719085451d6ee296f276", "packagetype": "bdist_wheel", "python_version": "py2", "requires_python": null, "size": 40388, "upload_time": "2018-06-23T18:02:03", "url": "https://files.pythonhosted.org/packages/39/32/6270627dbe5fac75409fb98eb42140017f7161d43c233dc68b4d70f6a874/senaite.api-1.2.3.post1-py2-none-any.whl" }, { "comment_text": "", "digests": { "md5": "b4fc7d14121646b107d2bf7d7d8f22f7", "sha256": "ca40d7f53f0fef3d2555ef3893a3276cf54c8003832ca417b4ceb264fafc1c96" }, "downloads": -1, "filename": "senaite.api-1.2.3.post1.zip", "has_sig": false, "md5_digest": "b4fc7d14121646b107d2bf7d7d8f22f7", "packagetype": "sdist", "python_version": "source", "requires_python": null, "size": 65295, "upload_time": "2018-06-23T18:02:05", "url": "https://files.pythonhosted.org/packages/ec/a9/1f7fb502fa9fdfb519bf015c22ae2dc4c74205ca3736bcbba0abd63c7c33/senaite.api-1.2.3.post1.zip" } ], "1.2.3.post2": [ { "comment_text": "", "digests": { "md5": "4fc28c7393bd344fe0f24abb7fb3944e", "sha256": "f5bcb758e417d536636572659140b11c29f7ef99b1eda7908205dda55fa089d6" }, "downloads": -1, "filename": "senaite.api-1.2.3.post2-py2-none-any.whl", "has_sig": false, "md5_digest": "4fc28c7393bd344fe0f24abb7fb3944e", "packagetype": "bdist_wheel", "python_version": "py2", "requires_python": null, "size": 40415, "upload_time": "2018-06-23T18:04:08", "url": "https://files.pythonhosted.org/packages/ed/23/1aab6813ef25e02975476acc0016ecd090ec58ff76b3f0f123898c7a2c8c/senaite.api-1.2.3.post2-py2-none-any.whl" }, { "comment_text": "", "digests": { "md5": "7bab7b339babc3700510c7abd9fb7cff", "sha256": "0f9a4d35e82624b161656863932c3f5bd22427d159cf793d60ae9a9b0712a6a9" }, "downloads": -1, "filename": "senaite.api-1.2.3.post2.zip", "has_sig": false, "md5_digest": "7bab7b339babc3700510c7abd9fb7cff", "packagetype": "sdist", "python_version": "source", "requires_python": null, "size": 65382, "upload_time": "2018-06-23T18:04:10", "url": "https://files.pythonhosted.org/packages/46/f9/837045a9fc949025d60384336013895beed98159141b6d22fa47227ffa3e/senaite.api-1.2.3.post2.zip" } ] }, "urls": [ { "comment_text": "", "digests": { "md5": "4fc28c7393bd344fe0f24abb7fb3944e", "sha256": "f5bcb758e417d536636572659140b11c29f7ef99b1eda7908205dda55fa089d6" }, "downloads": -1, "filename": "senaite.api-1.2.3.post2-py2-none-any.whl", "has_sig": false, "md5_digest": "4fc28c7393bd344fe0f24abb7fb3944e", "packagetype": "bdist_wheel", "python_version": "py2", "requires_python": null, "size": 40415, "upload_time": "2018-06-23T18:04:08", "url": "https://files.pythonhosted.org/packages/ed/23/1aab6813ef25e02975476acc0016ecd090ec58ff76b3f0f123898c7a2c8c/senaite.api-1.2.3.post2-py2-none-any.whl" }, { "comment_text": "", "digests": { "md5": "7bab7b339babc3700510c7abd9fb7cff", "sha256": "0f9a4d35e82624b161656863932c3f5bd22427d159cf793d60ae9a9b0712a6a9" }, "downloads": -1, "filename": "senaite.api-1.2.3.post2.zip", "has_sig": false, "md5_digest": "7bab7b339babc3700510c7abd9fb7cff", "packagetype": "sdist", "python_version": "source", "requires_python": null, "size": 65382, "upload_time": "2018-06-23T18:04:10", "url": "https://files.pythonhosted.org/packages/46/f9/837045a9fc949025d60384336013895beed98159141b6d22fa47227ffa3e/senaite.api-1.2.3.post2.zip" } ] }