diff --git a/README.md b/README.md index 3d9fc1f..9650f4e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,20 @@ Barcode generation using postgres sequences and pre-defined prefixes. +## Features + +Baracoda is a JSON-based microservice written in Python and backed in a +PostgreSQL database, with the purpose of handling the creation +of new barcodes for the LIMS application supported currently in PSD. + +These are some of the key features currently supported: + +* Creation of single barcodes +* Creation of group of barcodes +* Support for children barcodes creation +* Retrieval of the last barcode created for a prefix +* Support for different barcode formats + ## Table of Contents @@ -24,6 +38,8 @@ Barcode generation using postgres sequences and pre-defined prefixes. - [Autogenerating Migrations](#autogenerating-migrations) - [Routes](#routes) - [Miscellaneous](#miscellaneous) + * [Configuration](#configuration) + * [Children barcode creation](#children-barcode-creation) * [Troubleshooting](#troubleshooting) + [Installing psycopg2](#installing-psycopg2) * [Updating the Table of Contents](#updating-the-table-of-contents) @@ -151,11 +167,105 @@ The following routes are available from this service: barcode_creation.get_last_barcode GET /barcodes//last barcode_creation.get_new_barcode POST /barcodes//new barcode_creation.get_new_barcode_group POST /barcodes_group//new + child_barcodes.new_child_barcodes POST /child-barcodes//new health_check GET /health static GET /static/ ## Miscellaneous +### Configuration + +The default configuration of the currently supported prefixes is specified in the +```baracoda/config/defaults.py``` module. For example: +```json + { + "prefix": "HT", + "sequence_name": "ht", + "formatter_class": GenericBarcodeFormatter, + "enableChildrenCreation": False, + } +``` +These are the allowed keywords that we can specify to configure a prefix: + +- ```prefix```: This is the string that represents the prefix we are configuring for +supporting new barcodes. +- ```sequence_name```: This is the sequence name in the PostgreSQL database which will +keep record of the last index created for a barcode. Prefixes can share the same +sequence. +- ```formatter_class```: Defines the class that will generate the string that represents +a new barcode by using the prefix and the new value obtained from the sequence. +If we want to support a new formatter class we have to provide a class that implements +the interface ```baracoda.formats.FormatterInterface```. +- ```enableChildrenCreation```: Defines if the prefix has enabled the children creation. +If true, the prefix will support creating barcodes based on a parent barcode +If it is False, the prefix will reject any children creation request for that prefix. + +### Children barcode creation + +Children barcodes from a parent barcode can be created with a POST request to the +endpoint with a JSON body: + +```/child-barcodes//new``` +All barcodes for the children will have this prefix (example, prefix HT will generate children +like HT-11111-1, HT-11111-2, etc) +- *Parent Barcode* : barcode that will act as parent of the children. This argument will be +extracted from the Body of the request, eg: ```{'barcode': 'HT-1-1', 'count': 2}```. +To be considered valid, the barcode needs to follow the format ```-(-)?``` +where the last number part is optional (it represents if the barcode was a child). +For example, valid barcodes would be ```HT-11111-13``` (normal parent) and ```HT-11112-24``` +(parent that was a child) but not ```HT-1-1-1``` or ```HT12341-1```. +- *Child* : part of the Parent barcode string that would identify if the parent was +a child before (the last number). For example for the *Parent barcode* ```HT-11111-14```, +*Child* would be 14; but for the *Parent barcode* ```HT-11111```, *Child* would have no +value defined. +- *Count* : number of children barcodes to create. + +#### Wrong parent barcodes + +A request with parent barcode that does not follow the format defined like: +``` +-(-)? +``` +will create normal barcodes instead of suffixed children barcodes (normal barcode creation). + +A request that follows the right format but does not comply with current database will be +rejected as impostor barcode. For example, if we receive the parent barcode ```HT-1111-14```, but in the +database the parent ```HT-1111``` has only created 12 child barcodes yet, so ```HT-1111-14``` is +impossible to have been generated by Baracoda. + +#### Logic Workflow + +The following diagram describes the workflow of how this endpoint will behave depending on +the inputs declared before: + + +```mermaid +graph TD; + Prefix(Prefix is extracted from URL) --> PrefixEnabled[[Is 'Prefix' enabled by config to create children]]; + ParentBarcode(Parent barcode is extracted from URL arg) --> PrefixEnabled[[Is 'Prefix' enabled by config to create children]]; + Child(Child is extracted from Parent barcode) --> PrefixEnabled[[Is 'Prefix' enabled by config to create children]]; + PrefixEnabled -->|Yes|ValidBarcode[[Is 'Parent Barcode' a valid parent?]]; + PrefixEnabled -->|No|Rejected([HTTP 422 - Rejected]); + ValidBarcode -->|Yes|ParentPresent[[Is 'Parent barcode' present in database?]]; + ValidBarcode -->|No|NormalBarcode(Generate normal barcodes for the Prefix); + NormalBarcode -->NormalAccept([HTTP 201 - Created]) + ParentPresent -->|Yes|ChildExist[[Do we have a value for 'Child'?]]; + ParentPresent -->|No|ChildExist2[[Do we have a value for 'Child'?]]; + ChildExist -->|Yes|ChildConstraint[[Is 'Child' bigger than the last child generated by the 'Parent barcode' in previous requests?]]; + ChildConstraint -->|Yes|Impostor([HTTP 500 - Parent barcode is an impostor]); + ChildExist2 -->|Yes|Impostor; + ChildExist -->|No|ChildrenBarcodes(Generate children barcodes); + ChildrenBarcodes --> NormalAccept; + ChildConstraint -->|No|ChildrenBarcodes + ChildExist2 -->|No|ChildrenBarcodes; + +``` + ### Troubleshooting #### Installing psycopg2 diff --git a/alembic/versions/e501839465f6_add_sqp_sequence.py b/alembic/versions/e501839465f6_add_sqp_sequence.py new file mode 100644 index 0000000..7b0bf61 --- /dev/null +++ b/alembic/versions/e501839465f6_add_sqp_sequence.py @@ -0,0 +1,23 @@ +"""Add sqp sequence + +Revision ID: e501839465f6 +Revises: bc442d63d7d3 +Create Date: 2022-04-04 14:38:35.746245 + +""" +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "e501839465f6" +down_revision = "bc442d63d7d3" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute("CREATE SEQUENCE sqp START 1;") + + +def downgrade(): + op.execute("DROP SEQUENCE IF EXISTS sqp;") diff --git a/alembic/versions/e505f2e15499_add_child_barcode_counter_table.py b/alembic/versions/e505f2e15499_add_child_barcode_counter_table.py new file mode 100644 index 0000000..e698e4b --- /dev/null +++ b/alembic/versions/e505f2e15499_add_child_barcode_counter_table.py @@ -0,0 +1,33 @@ +"""add child barcode counter table + + +Revision ID: e505f2e15499 +Revises: e501839465f6 +Create Date: 2022-04-06 14:58:18.990940 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "e505f2e15499" +down_revision = "e501839465f6" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "child_barcode_counter", + # ID + sa.Column("barcode", sa.String(50), nullable=False), + sa.Column("child_count", sa.Integer(), nullable=True), + # Created_at + sa.PrimaryKeyConstraint("barcode"), + ) + + +def downgrade(): + op.drop_table("child_barcode_counter") + # ### end Alembic commands ### diff --git a/baracoda/__init__.py b/baracoda/__init__.py index 64ab192..298201d 100644 --- a/baracoda/__init__.py +++ b/baracoda/__init__.py @@ -7,6 +7,7 @@ from flask.cli import with_appcontext from baracoda import barcodes +from baracoda import child_barcodes from baracoda.config.logging import LOGGING from baracoda.db import db, reset_db @@ -41,6 +42,7 @@ def create_app(test_config=None): app.cli.add_command(init_db_command) app.register_blueprint(barcodes.bp) + app.register_blueprint(child_barcodes.bp) @app.route("/health") def health_check(): diff --git a/baracoda/barcodes.py b/baracoda/barcodes.py index ebe94ff..684b2b0 100644 --- a/baracoda/barcodes.py +++ b/baracoda/barcodes.py @@ -16,7 +16,26 @@ @bp.post("/barcodes_group//new") # type: ignore def get_new_barcode_group(prefix: str) -> Tuple[Any, int]: + """Endpoint that creates a new group of barcodes that are related in one request + + Arguments: + - prefix : str - URL extracted argument, that defines the Prefix to use for + the barcodes generated. It has to be one of the prefixes defined in + baracoda.config PREFIXES variable + - count : str - URL or BODY extracted argument. It represents the number of + barcodes we want to create inside the group. + If specified in URL it can be defined as url parameter: + Eg: /barcodes_group/TEST/new?count=14 + If specified in BODY it has to be defined as jSON: + Eg: { "count": 14 } + Result: + - Success: HTTP 201 with JSON representation of BarcodeGroup instance + - InvalidPrefixError: HTTP 400 with JSON representation of error. + - InvalidCountError: HTTP 422 with JSON representation of error. + - OtherError: HTTP 500 with JSON representation of error. + """ try: + logger.debug(f"Creating a barcode group for '{ prefix }'") count = get_count_param() operator = BarcodeOperations(prefix=prefix) @@ -36,7 +55,19 @@ def get_new_barcode_group(prefix: str) -> Tuple[Any, int]: @bp.post("/barcodes//new") # type: ignore def get_new_barcode(prefix: str) -> Tuple[Any, int]: + """Endpoint that creates one single barcode for a prefix + + Arguments: + - prefix : str - URL extracted argument, that defines the Prefix to use for + the barcode generated. It has to be one of the prefixes defined in + baracoda.config PREFIXES variable + Result: + - Success: HTTP 201 with JSON representation of Barcode instance + - InvalidPrefixError: HTTP 400 with JSON representation of error. + - OtherError: HTTP 500 with JSON representation of error. + """ try: + logger.debug(f"Creating a barcode for '{ prefix }'") operator = BarcodeOperations(prefix=prefix) barcode = operator.create_barcode() @@ -50,7 +81,20 @@ def get_new_barcode(prefix: str) -> Tuple[Any, int]: @bp.get("/barcodes//last") # type: ignore def get_last_barcode(prefix: str) -> Tuple[Any, int]: + """Endpoint that returns the last generated barcode for a specific prefix + + Arguments: + - prefix : str - URL extracted argument, that defines the Prefix we want + to queryu. It has to be one of the prefixes defined in + baracoda.config PREFIXES variable + Result: + - Success: HTTP 200 with JSON representation of barcode instance + - NotFound: HTTP 404 with empty body + - InvalidPrefixError: HTTP 400 with JSON representation of error. + - OtherError: HTTP 500 with JSON representation of error. + """ try: + logger.debug(f"Obtaining last from '{ prefix }'") operator = BarcodeOperations(prefix=prefix) barcode = operator.get_last_barcode(prefix) @@ -65,6 +109,18 @@ def get_last_barcode(prefix: str) -> Tuple[Any, int]: def get_count_param(): + """Extracts the count argument from the HTTP request received. + If specified in URL it can be defined as url parameter: + Eg: /barcodes_group/TEST/new?count=14 + If specified in BODY it has to be defined as jSON: + Eg: { "count": 14 } + + Arguments: No + Returns one of this: + int - value of the 'count' argument extracted + InvalidCountError - Exception raised when argument could not be extracted + + """ if "count" in request.values: return int(request.values["count"]) else: diff --git a/baracoda/child_barcodes.py b/baracoda/child_barcodes.py new file mode 100644 index 0000000..1fc1fc8 --- /dev/null +++ b/baracoda/child_barcodes.py @@ -0,0 +1,105 @@ +import logging +from http import HTTPStatus +from typing import Any, Tuple +from flask import Blueprint, request +from sqlalchemy import exc +from flask_cors import CORS + +from baracoda.operations import BarcodeOperations, InvalidParentBarcode, InvalidPrefixForChildrenCreation +from baracoda.exceptions import InvalidCountError, InvalidBarcodeError +from baracoda.types import BarcodeParentInfoType +from typing import cast + +bp = Blueprint("child_barcode_creation", __name__) +CORS(bp) + +logger = logging.getLogger(__name__) + + +@bp.post("/child-barcodes//new") # type: ignore +def new_child_barcodes(prefix: str) -> Tuple[Any, int]: + """Endpoint that creates a new group of child barcodes from a parent barcode + provided, all in one single request. + + Arguments: + - prefix : str - URL extracted argument, that defines the Prefix to use for + the barcodes generated. It has to be one of the prefixes defined in + baracoda.config PREFIXES variable + - count : str - URL or BODY extracted argument. It represents the number of + barcodes we want to create inside the group. + If specified in URL it can be defined as url parameter: + Eg: /barcodes_group/TEST/new?count=14 + If specified in BODY it has to be defined as jSON: + Eg: { "count": 14 } + Result: + - Success: HTTP 201 with JSON representation of BarcodeGroup instance + - InvalidPrefixError: HTTP 400 with JSON representation of error. + - InvalidCountError: HTTP 422 with JSON representation of error. + - OtherError: HTTP 500 with JSON representation of error. + """ + try: + count = get_count_param() + barcode = get_barcode_param() + + logger.debug(f"Creating child barcode(s) for '{barcode}'") + + operator = BarcodeOperations(prefix=prefix) + + if not operator.is_valid_parent_barcode(barcode): + barcode_group = operator.create_barcode_group(count) + else: + operator.validate_prefix_for_child_creation() + info = operator.extract_barcode_parent_information(barcode) + operator.validate_barcode_parent_information(info) + barcode_group = operator.create_children_barcode_group( + cast(BarcodeParentInfoType, info)["parent_barcode"], count + ) + return ( + barcode_group.to_dict(), + HTTPStatus.CREATED, + ) + except InvalidCountError as e: + return {"errors": [f"{type(e).__name__}"]}, HTTPStatus.UNPROCESSABLE_ENTITY + except InvalidBarcodeError as e: + return {"errors": [f"{type(e).__name__}"]}, HTTPStatus.UNPROCESSABLE_ENTITY + except exc.IntegrityError as e: + logger.error(f"{type(e).__name__}: Two creation requests recieved for the same barcode") + return {"errors": [f"{type(e).__name__}"]}, HTTPStatus.INTERNAL_SERVER_ERROR + except InvalidParentBarcode as e: + return {"errors": [f"{type(e).__name__}"]}, HTTPStatus.INTERNAL_SERVER_ERROR + except InvalidPrefixForChildrenCreation as e: + return {"errors": [f"{type(e).__name__}"]}, HTTPStatus.INTERNAL_SERVER_ERROR + except Exception as e: + return {"errors": [f"{type(e).__name__}"]}, HTTPStatus.INTERNAL_SERVER_ERROR + + +def get_count_param(): + """Extracts the count param from the Body of the request. If count + was not defined it will return 1. + + Returns: + int with the count value or + InvalidCountError if it could not be extracted + """ + count = 1 # Default count + if request.json and ("count" in request.json): + count = int(request.json["count"]) + if count > 0: + return count + raise InvalidCountError() + + +def get_barcode_param(): + """Extracts the barcode param from the Body of the request. If barcode + was not defined it will raise InvalidBarcodeError. + + Returns: + str with the barcode value or + InvalidBarcodeError if it was not present + """ + barcode = "" + if request.json and ("barcode" in request.json): + barcode = str(request.json["barcode"]) + if len(barcode.strip()) > 0: + return barcode + raise InvalidBarcodeError() diff --git a/baracoda/config/defaults.py b/baracoda/config/defaults.py index a6a35f4..d34db25 100644 --- a/baracoda/config/defaults.py +++ b/baracoda/config/defaults.py @@ -1,10 +1,13 @@ -from typing import Any, Dict, List +from typing import List +from baracoda.types import PrefixesType + +from baracoda.formats import HeronCogUkIdFormatter, GenericBarcodeFormatter ### # database config ### DB_DBNAME = "baracoda_dev" -DB_HOST = "127.0.0.1" +DB_HOST = "localhost" DB_PASSWORD = "postgres" DB_PORT = "5432" DB_USER = "postgres" @@ -25,59 +28,305 @@ ### # prefix for barcodes returned from the respective sequece ### -PREFIXES: List[Dict[str, Any]] = [ - {"prefix": "ALDP", "sequence_name": "heron", "convert": True}, - {"prefix": "BHRT", "sequence_name": "heron", "convert": True}, - {"prefix": "BIRM", "sequence_name": "heron", "convert": True}, - {"prefix": "BRBR", "sequence_name": "heron", "convert": True}, - {"prefix": "BRIG", "sequence_name": "heron", "convert": True}, - {"prefix": "BRIS", "sequence_name": "heron", "convert": True}, - {"prefix": "CAMB", "sequence_name": "heron", "convert": True}, - {"prefix": "CAMC", "sequence_name": "heron", "convert": True}, - {"prefix": "CPTD", "sequence_name": "heron", "convert": True}, - {"prefix": "CWAR", "sequence_name": "heron", "convert": True}, - {"prefix": "EDIN", "sequence_name": "heron", "convert": True}, - {"prefix": "EKHU", "sequence_name": "heron", "convert": True}, - {"prefix": "EXET", "sequence_name": "heron", "convert": True}, - {"prefix": "GCVR", "sequence_name": "heron", "convert": True}, - {"prefix": "GLOU", "sequence_name": "heron", "convert": True}, - {"prefix": "GSTT", "sequence_name": "heron", "convert": True}, - {"prefix": "HECH", "sequence_name": "heron", "convert": True}, - {"prefix": "HSLL", "sequence_name": "heron", "convert": True}, - {"prefix": "KGHT", "sequence_name": "heron", "convert": True}, - {"prefix": "LCST", "sequence_name": "heron", "convert": True}, - {"prefix": "LEED", "sequence_name": "heron", "convert": True}, - {"prefix": "LIVE", "sequence_name": "heron", "convert": True}, - {"prefix": "LOND", "sequence_name": "heron", "convert": True}, - {"prefix": "LSPA", "sequence_name": "heron", "convert": True}, - {"prefix": "MILK", "sequence_name": "heron", "convert": True}, - {"prefix": "MTUN", "sequence_name": "heron", "convert": True}, - {"prefix": "NEWC", "sequence_name": "heron", "convert": True}, - {"prefix": "NIRE", "sequence_name": "heron", "convert": True}, - {"prefix": "NORT", "sequence_name": "heron", "convert": True}, - {"prefix": "NORW", "sequence_name": "heron", "convert": True}, - {"prefix": "NOTT", "sequence_name": "heron", "convert": True}, - {"prefix": "NWGH", "sequence_name": "heron", "convert": True}, - {"prefix": "OXON", "sequence_name": "heron", "convert": True}, - {"prefix": "PAHT", "sequence_name": "heron", "convert": True}, - {"prefix": "PHEC", "sequence_name": "heron", "convert": True}, - {"prefix": "PHWC", "sequence_name": "heron", "convert": True}, - {"prefix": "PLYM", "sequence_name": "heron", "convert": True}, - {"prefix": "PORT", "sequence_name": "heron", "convert": True}, - {"prefix": "PRIN", "sequence_name": "heron", "convert": True}, - {"prefix": "QEUH", "sequence_name": "heron", "convert": True}, - {"prefix": "RAND", "sequence_name": "heron", "convert": True}, - {"prefix": "RSCH", "sequence_name": "heron", "convert": True}, - {"prefix": "SANG", "sequence_name": "heron", "convert": True}, - {"prefix": "SHEF", "sequence_name": "heron", "convert": True}, - {"prefix": "TBSD", "sequence_name": "heron", "convert": True}, - {"prefix": "TFCI", "sequence_name": "heron", "convert": True}, - {"prefix": "WAHH", "sequence_name": "heron", "convert": True}, - {"prefix": "WSFT", "sequence_name": "heron", "convert": True}, - {"prefix": "HT", "sequence_name": "ht", "convert": False}, + +PREFIXES: List[PrefixesType] = [ + { + "prefix": "ALDP", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "BHRT", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "BIRM", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "BRBR", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "BRIG", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "BRIS", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "CAMB", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "CAMC", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "CPTD", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "CWAR", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "EDIN", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "EKHU", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "EXET", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "GCVR", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "GLOU", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "GSTT", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "HECH", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "HSLL", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "KGHT", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "LCST", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "LEED", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "LIVE", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "LOND", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "LSPA", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "MILK", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "MTUN", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "NEWC", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "NIRE", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "NORT", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "NORW", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "NOTT", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "NWGH", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "OXON", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "PAHT", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "PHEC", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "PHWC", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "PLYM", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "PORT", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "PRIN", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "QEUH", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "RAND", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "RSCH", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "SANG", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "SHEF", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "TBSD", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "TFCI", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "WAHH", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "WSFT", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "HT", + "sequence_name": "ht", + "formatter_class": GenericBarcodeFormatter, + "enableChildrenCreation": False, + }, ] for prefix_item in PREFIXES: - for key in ["prefix", "sequence_name", "convert"]: + for key in ["prefix", "sequence_name", "formatter_class"]: if not (key in prefix_item): - raise KeyError("PREFIXES must all contain a prefix, sequence_name and convert key.") + raise KeyError("PREFIXES must all contain a prefix, sequence_name and formatter_class key.") diff --git a/baracoda/config/development.py b/baracoda/config/development.py index 363f09b..c88a224 100644 --- a/baracoda/config/development.py +++ b/baracoda/config/development.py @@ -1,8 +1,26 @@ # flake8: noqa from baracoda.config.defaults import * +from baracoda.formats import GenericBarcodeFormatter + +# Adds a development prefix for sqp sequence +PREFIXES.append( + { + "prefix": "SQPD", + "sequence_name": "sqp", + "formatter_class": GenericBarcodeFormatter, + "enableChildrenCreation": True, + } +) # settings here overwrite those in 'defaults.py' # adding an additional barcode prefix for use in source plate data setup # for beckman and biosero, sharing the ht sequence -PREFIXES.append({"prefix": "TEST", "sequence_name": "ht", "convert": False}) +PREFIXES.append( + { + "prefix": "TEST", + "sequence_name": "ht", + "formatter_class": GenericBarcodeFormatter, + "enableChildrenCreation": True, + } +) diff --git a/baracoda/config/logging.py b/baracoda/config/logging.py index 1a31c91..a9c85fa 100644 --- a/baracoda/config/logging.py +++ b/baracoda/config/logging.py @@ -50,7 +50,7 @@ "loggers": { "baracoda": { "handlers": ["console", "slack"], - "level": "INFO", + "level": "ERROR", "propagate": True, }, }, diff --git a/baracoda/config/test.py b/baracoda/config/test.py index ac27742..01829b6 100644 --- a/baracoda/config/test.py +++ b/baracoda/config/test.py @@ -11,4 +11,5 @@ # database config ### DB_DBNAME = "baracoda_test" +DB_HOST = "localhost" SQLALCHEMY_DATABASE_URI = f"postgresql+psycopg2://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_DBNAME}" diff --git a/baracoda/exceptions.py b/baracoda/exceptions.py index 3564437..0ec6e4c 100644 --- a/baracoda/exceptions.py +++ b/baracoda/exceptions.py @@ -20,7 +20,7 @@ def __str__(self): class InvalidCountError(Error): - """Raised when a param count for a Heron barcode group is not found.""" + """Raised when a param count for a Heron barcode group or child barcode is not found.""" def __init__(self, message: str = ""): self.message = message @@ -32,3 +32,18 @@ def __str__(self): return f"InvalidCountError: {self.message}" else: return f"InvalidCountError: {default_message}" + + +class InvalidBarcodeError(Error): + """Raised when a barcode param is not given for a child barcode""" + + def __init__(self, message: str = ""): + self.message = message + + def __str__(self): + default_message = "Please add the 'barcode' param to the request" + + if self.message: + return f"InvalidBarcodeError: {self.message}" + else: + return f"InvalidBarcodeError: {default_message}" diff --git a/baracoda/formats.py b/baracoda/formats.py index 469c947..e111c90 100644 --- a/baracoda/formats.py +++ b/baracoda/formats.py @@ -3,14 +3,39 @@ logger = logging.getLogger(__name__) -class HeronFormatter: - def __init__(self, prefix: str, convert: bool = True): +class FormatterInterface: + def barcode(self, value: int) -> str: + pass + + +class GenericBarcodeFormatter(FormatterInterface): + def __init__(self, prefix: str): logger.debug(f"Instantiate formatter with {prefix}") self.prefix = prefix - # tells the formatter whether barcode needs to be mashed up - self.convert = convert + def barcode(self, value: int) -> str: + """ + Method which returns a barcode with a prefix. + If the barcode needs to be converted it is formatted otherwise it is returned as is + + Arguments: + value {str} -- the value of the barcode from the sequence + + Returns: + str -- formatted barcode with prefix and checksum + """ + + formatted_value = value + + return f"{self.prefix}-{formatted_value}" + + +class HeronCogUkIdFormatter(FormatterInterface): + def __init__(self, prefix: str): + logger.debug(f"Instantiate formatter with {prefix}") + + self.prefix = prefix def hex_to_int(self, hex_str: str) -> int: """Convert a hex string to integer. @@ -67,7 +92,7 @@ def barcode(self, value: int) -> str: str -- formatted barcode with prefix and checksum """ - formatted_value = self.format_barcode_number(value) if self.convert else value + formatted_value = self.format_barcode_number(value) return f"{self.prefix}-{formatted_value}" diff --git a/baracoda/helpers.py b/baracoda/helpers.py index 7232509..1b3efb2 100644 --- a/baracoda/helpers.py +++ b/baracoda/helpers.py @@ -1,12 +1,13 @@ import logging -from typing import Dict, Optional, Union +from typing import Optional +from baracoda.types import PrefixesType from flask import current_app logger = logging.getLogger(__name__) -def get_prefix_item(prefix: str) -> Optional[Dict[str, Union[str, bool]]]: +def get_prefix_item(prefix: str) -> Optional[PrefixesType]: """ Method which returns prefix object. This will contain the prefix, the sequence and whether any barcode for that prefix needs to be formatted. @@ -15,7 +16,7 @@ def get_prefix_item(prefix: str) -> Optional[Dict[str, Union[str, bool]]]: value {str} -- the actual prefix Returns: - Dict -- prefix object e.g. { "prefix": "abc", "sequence_name": "seq", "convert": False} or + Dict -- prefix object e.g. { "prefix": "abc", "sequence_name": "seq" } or None -- if the prefix item is not available """ return next((item for item in current_app.config["PREFIXES"] if item["prefix"] == prefix), None) diff --git a/baracoda/migrations/script.py.mako b/baracoda/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/baracoda/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/baracoda/operations.py b/baracoda/operations.py index b558971..bf50400 100644 --- a/baracoda/operations.py +++ b/baracoda/operations.py @@ -2,17 +2,36 @@ import re from datetime import datetime from typing import List, Optional, cast - +from xmlrpc.client import Boolean from baracoda.db import db from baracoda.exceptions import InvalidPrefixError -from baracoda.formats import HeronFormatter from baracoda.helpers import get_prefix_item from baracoda.orm.barcode import Barcode +from baracoda.orm.child_barcode import ChildBarcode from baracoda.orm.barcodes_group import BarcodesGroup +from baracoda.formats import FormatterInterface +from baracoda.types import PrefixesType, BarcodeParentInfoType logger = logging.getLogger(__name__) +class InvalidParentBarcode(BaseException): + """The barcode provided did not match the right format, or + it was an impostor barcode (created outside of Baracoda but + using the same format). + """ + + pass + + +class InvalidPrefixForChildrenCreation(BaseException): + """The prefix provided is not currently enabled for children + creation. + """ + + pass + + class BarcodeOperations: def __init__(self, prefix: str): logger.debug("Instantiate....") @@ -21,6 +40,7 @@ def __init__(self, prefix: str): self.__check_prefix() + logger.debug(f"Setting prefix item from prefix {self.prefix}") self.__set_prefix_item() # if the prefix item does not exist the prefix is not valid @@ -28,9 +48,34 @@ def __init__(self, prefix: str): raise InvalidPrefixError() # saves pulling it out of object every time + logger.debug("Accessing sequence_name") self.sequence_name = self.prefix_item["sequence_name"] - self.formatter = HeronFormatter(prefix=self.prefix, convert=self.prefix_item["convert"]) # type: ignore + def formatter(self) -> FormatterInterface: + """Factory method that will create a new formatter instance + from the prefix declared. + + Returns: + FormatterInterface instance that can be used to format a new + barcode string + """ + formatter_class = cast(PrefixesType, self.prefix_item)["formatter_class"] + return formatter_class(self.prefix) + + def create_barcodes(self, count: int) -> List[str]: + """Create a list of barcodes, not inside a group. + It requests a new list of ids from the sequence associated with the current prefix + and formats those ids into new barcode strings. The sequence is incremented with this + request. + + Arguments: + count - int : number of barcodes to create + + Returns: + List[str] - List with the string of barcodes created + """ + next_values = self.__get_next_values(self.sequence_name, count) + return [self.formatter().barcode(next_value) for next_value in next_values] def create_barcode_group(self, count: int) -> BarcodesGroup: """Creates a new barcode group and the associated barcodes. @@ -41,24 +86,21 @@ def create_barcode_group(self, count: int) -> BarcodesGroup: Returns: BarcodeGroup -- the barcode group created """ - try: - next_values = self.__get_next_values(self.sequence_name, count) # type: ignore - - barcodes_group = self.__build_barcodes_group() - db.session.add(barcodes_group) + next_values = self.__get_next_values(self.sequence_name, count) + barcodes = [self.formatter().barcode(next_value) for next_value in next_values] + return self.__create_barcode_group(barcodes) - barcodes = [ - self.__build_barcode(self.prefix, next_value, barcodes_group=barcodes_group) - for next_value in next_values - ] - db.session.add_all(barcodes) + def create_children_barcode_group(self, parent_barcode: str, count: int) -> BarcodesGroup: + """Creates a new barcode group and the associated barcodes. - db.session.commit() + Arguments: + count {int} -- number of barcodes to create in the group - return barcodes_group - except Exception as e: - db.session.rollback() - raise e + Returns: + BarcodeGroup -- the barcode group created + """ + barcodes = self.create_child_barcodes(parent_barcode, count) + return self.__create_barcode_group(barcodes) def create_barcode(self) -> Barcode: """Generate and store a barcode using the Heron formatter. @@ -66,9 +108,12 @@ def create_barcode(self) -> Barcode: Returns: str -- the generated barcode in the Heron format """ + logger.debug(f"Calling create_barcode for sequence name {self.sequence_name}") try: - next_value = self.__get_next_value(self.sequence_name) # type: ignore - barcode = self.__build_barcode(self.prefix, next_value, barcodes_group=None) + next_value = self.__get_next_value(self.sequence_name) + barcode = self.__build_barcode( + prefix=self.prefix, barcode=self.formatter().barcode(next_value), barcodes_group=None + ) db.session.add(barcode) @@ -120,8 +165,18 @@ def __validate_prefix(self) -> bool: return bool(pattern.match(self.prefix)) - def __build_barcode(self, prefix: str, next_value: int, barcodes_group: Optional[BarcodesGroup]) -> Barcode: - barcode = self.formatter.barcode(next_value) + def __build_barcode(self, prefix: str, barcode: str, barcodes_group: Optional[BarcodesGroup]) -> Barcode: + """Creates a new instance for Barcode with the arguments received, relating them to a + BarcodeGroup if provided, and setting a created_at timestamp. + + Arguments: + prefix : str - prefix of the barcode + barcode : str - string with the barcode value + barcodes_group : Optional[BarcodesGroup] - instance of BarcodesGroup or None if not needed + + Returns: + Barcode instance with the arguments set and a created_at timestamp attached + """ return Barcode( prefix=prefix, barcode=barcode, @@ -129,7 +184,41 @@ def __build_barcode(self, prefix: str, next_value: int, barcodes_group: Optional barcodes_group=barcodes_group, ) + def __create_barcode_group(self, barcodes: List[str]) -> BarcodesGroup: + """Creates a new barcode group and the associated barcodes. + + Arguments: + count {int} -- number of barcodes to create in the group + + Returns: + BarcodeGroup -- the barcode group created + """ + try: + barcodes_group = self.__build_barcodes_group() + db.session.add(barcodes_group) + + barcodes_instances = [ + self.__build_barcode(prefix=self.prefix, barcode=barcode, barcodes_group=barcodes_group) + for barcode in barcodes + ] + db.session.add_all(barcodes_instances) + + db.session.commit() + + return barcodes_group + except Exception as e: + db.session.rollback() + raise e + def __build_barcodes_group(self) -> BarcodesGroup: + """Creates a new instance for BarcodesGroup, and sets + a created_at timestamp. + + Arguments: None + + Returns: + BarcodesGroup instance with a created_at timestamp attached + """ return BarcodesGroup(created_at=datetime.now()) def __get_next_value(self, sequence_name: str) -> int: @@ -167,3 +256,113 @@ def __set_prefix_item(self): prefix item or None if prefix does not exist """ self.prefix_item = get_prefix_item(self.prefix) + + # Child barcode operations + def is_valid_parent_barcode(self, barcode: str) -> Boolean: + """Boolean function that identifies if a barcode can act as a parent barcode. + It checks that the barcode matches the format of the regexp declared in the + #extract_barcode_parent_information method. + + Arguments: + barcode - str : Barcode that we want to check if it is valid parent + + Returns: + bool indicating if the barcode was a valid parent + """ + return not self.extract_barcode_parent_information(barcode) is None + + def extract_barcode_parent_information(self, barcode: str) -> Optional[BarcodeParentInfoType]: + """Extracts the parent and child information from a barcode string by following the regexp + defined. If the input does not match it will return None. + Eg: barcode HT-1111-23 it will extract parent: HT-1111 and child: 23 + barcode HT-1111 it will extract parent: HT-1111 and child: None + + Arguments: + barcode - str : Barcode string where we want to extract data from + + Returns: + BarcodeParentInfoType object with the fields parent_barcode and child, or + None if the barcode string from input did not match the regexp. + """ + pattern = re.compile(f"^(?P{self.prefix}-\\d+)(?:-(?P\\d+))?$") + found = pattern.search(barcode) + if not found: + return None + return { + "parent_barcode": found.group("parent_barcode"), + "child": found.group("child"), + } + + def validate_prefix_for_child_creation(self) -> None: + """Validates if self.prefix is declared as children creation enabled and if not + it will raise an exception. + + Returns: + None if prefix has children creation enabled + Raise InvalidPrefixForChildrenCreation if not enabled + """ + if not cast(PrefixesType, self.prefix_item)["enableChildrenCreation"]: + raise InvalidPrefixForChildrenCreation() + + def validate_barcode_parent_information(self, info: Optional[BarcodeParentInfoType]) -> None: + """Validates if barcode has all the correct information to generate children barcodes. + It will check that: + - The barcode was correctly parsed in the object as input, otherwise it will raise + InvalidParentBarcode + - The parent barcode, if is a child barcode, it was generated as a child before by Baracoda + otherwise it will be rejected and raise InvalidParentBarcode + + Returns: + None if checks were ok + Raise InvalidParentBarcode if not correct + """ + if not info: + raise InvalidParentBarcode("The barcode provided is not valid for generating child barcodes") + + barcode_record = db.session.query(ChildBarcode).filter_by(barcode=info["parent_barcode"]).first() + + if barcode_record is None: + if info["child"]: + raise InvalidParentBarcode("The barcode provided is an impostor barcode. It has no parent.") + return + + if info["child"]: + child_position = int(info["child"]) + if barcode_record.child_count < child_position: + raise InvalidParentBarcode( + "The barcode provided is an impostor barcode. Its parent has not generated this position yet." + ) + + def create_child_barcodes(self, parent_barcode: str, count: int) -> List[str]: + """Retrieve the next child barcodes for a given barcode + + Returns: + [str] -- The generated child barcodes + """ + try: + # Check barcode exists + barcode_record = db.session.query(ChildBarcode).with_for_update().filter_by(barcode=parent_barcode).first() + + # If no record, then create one + if barcode_record is None: + old_count = 0 + barcode_record = ChildBarcode(barcode=parent_barcode, child_count=count) + db.session.add(barcode_record) + else: + old_count = barcode_record.child_count + barcode_record.child_count = old_count + count + + db.session.commit() + + # We want the new count to start at the next number + new_count = old_count + 1 + + # Format child barcodes + child_barcodes = [] + for x in range(new_count, barcode_record.child_count + 1): + child_barcodes.append(f"{parent_barcode}-{x}") + + return child_barcodes + except Exception as e: + db.session.rollback() + raise e diff --git a/baracoda/orm/child_barcode.py b/baracoda/orm/child_barcode.py new file mode 100644 index 0000000..dd3eb14 --- /dev/null +++ b/baracoda/orm/child_barcode.py @@ -0,0 +1,23 @@ +from sqlalchemy import Column, Integer, String +from baracoda.orm.base import Base + + +class ChildBarcode(Base): + """Class that will define the ORM mapping with the database. It will + store a list of all parent barcodes with an index of the last created + child for the parent for each entry. + """ + + __tablename__ = "child_barcode_counter" + + barcode = Column(String(50), nullable=False, primary_key=True) + child_count = Column(Integer, nullable=True) + + def __repr__(self): + return "" % ( + self.barcode, + self.child_count, + ) + + def to_dict(self): + return {"barcode": self.barcode, "child_count": self.child_count} diff --git a/baracoda/sql/schema.sql b/baracoda/sql/schema.sql index 2d74164..45f17a2 100644 --- a/baracoda/sql/schema.sql +++ b/baracoda/sql/schema.sql @@ -6,8 +6,11 @@ DROP SEQUENCE IF EXISTS ht; CREATE SEQUENCE ht START 111111; -DROP TABLE IF EXISTS barcodes; +DROP SEQUENCE IF EXISTS sqp; +CREATE SEQUENCE sqp +START 1; +DROP TABLE IF EXISTS barcodes; CREATE TABLE barcodes ( id SERIAL, @@ -26,6 +29,14 @@ CREATE TABLE barcodes_groups PRIMARY KEY (id) ); +DROP TABLE IF EXISTS child_barcode_counter; +CREATE TABLE child_barcode_counter +( + barcode VARCHAR(50) NOT NULL, + child_count integer, + PRIMARY KEY (barcode) +); + DROP TABLE IF EXISTS alembic_version; CREATE TABLE alembic_version ( diff --git a/baracoda/static/index.html b/baracoda/static/index.html index 1d40055..12c54d5 100644 --- a/baracoda/static/index.html +++ b/baracoda/static/index.html @@ -44,55 +44,55 @@ } var prefixes = [ - { "prefix": "ALDP", "sequence_name": "heron", "convert": true }, - { "prefix": "BHRT", "sequence_name": "heron", "convert": true }, - { "prefix": "BIRM", "sequence_name": "heron", "convert": true }, - { "prefix": "BRBR", "sequence_name": "heron", "convert": true }, - { "prefix": "BRIG", "sequence_name": "heron", "convert": true }, - { "prefix": "BRIS", "sequence_name": "heron", "convert": true }, - { "prefix": "CAMB", "sequence_name": "heron", "convert": true }, - { "prefix": "CAMC", "sequence_name": "heron", "convert": true }, - { "prefix": "CPTD", "sequence_name": "heron", "convert": true }, - { "prefix": "CWAR", "sequence_name": "heron", "convert": true }, - { "prefix": "EDIN", "sequence_name": "heron", "convert": true }, - { "prefix": "EKHU", "sequence_name": "heron", "convert": true }, - { "prefix": "EXET", "sequence_name": "heron", "convert": true }, - { "prefix": "GCVR", "sequence_name": "heron", "convert": true }, - { "prefix": "GLOU", "sequence_name": "heron", "convert": true }, - { "prefix": "GSTT", "sequence_name": "heron", "convert": true }, - { "prefix": "HECH", "sequence_name": "heron", "convert": true }, - { "prefix": "HSLL", "sequence_name": "heron", "convert": true }, - { "prefix": "KGHT", "sequence_name": "heron", "convert": true }, - { "prefix": "LCST", "sequence_name": "heron", "convert": true }, - { "prefix": "LEED", "sequence_name": "heron", "convert": true }, - { "prefix": "LIVE", "sequence_name": "heron", "convert": true }, - { "prefix": "LOND", "sequence_name": "heron", "convert": true }, - { "prefix": "LSPA", "sequence_name": "heron", "convert": true }, - { "prefix": "MILK", "sequence_name": "heron", "convert": true }, - { "prefix": "MTUN", "sequence_name": "heron", "convert": true }, - { "prefix": "NEWC", "sequence_name": "heron", "convert": true }, - { "prefix": "NIRE", "sequence_name": "heron", "convert": true }, - { "prefix": "NORT", "sequence_name": "heron", "convert": true }, - { "prefix": "NORW", "sequence_name": "heron", "convert": true }, - { "prefix": "NOTT", "sequence_name": "heron", "convert": true }, - { "prefix": "NWGH", "sequence_name": "heron", "convert": true }, - { "prefix": "OXON", "sequence_name": "heron", "convert": true }, - { "prefix": "PAHT", "sequence_name": "heron", "convert": true }, - { "prefix": "PHEC", "sequence_name": "heron", "convert": true }, - { "prefix": "PHWC", "sequence_name": "heron", "convert": true }, - { "prefix": "PLYM", "sequence_name": "heron", "convert": true }, - { "prefix": "PORT", "sequence_name": "heron", "convert": true }, - { "prefix": "PRIN", "sequence_name": "heron", "convert": true }, - { "prefix": "QEUH", "sequence_name": "heron", "convert": true }, - { "prefix": "RAND", "sequence_name": "heron", "convert": true }, - { "prefix": "RSCH", "sequence_name": "heron", "convert": true }, - { "prefix": "SANG", "sequence_name": "heron", "convert": true }, - { "prefix": "SHEF", "sequence_name": "heron", "convert": true }, - { "prefix": "TBSD", "sequence_name": "heron", "convert": true }, - { "prefix": "TFCI", "sequence_name": "heron", "convert": true }, - { "prefix": "WAHH", "sequence_name": "heron", "convert": true }, - { "prefix": "WSFT", "sequence_name": "heron", "convert": true }, - { "prefix": "HT", "sequence_name": "ht", "convert": false } + { "prefix": "ALDP", "sequence_name": "heron" }, + { "prefix": "BHRT", "sequence_name": "heron" }, + { "prefix": "BIRM", "sequence_name": "heron" }, + { "prefix": "BRBR", "sequence_name": "heron" }, + { "prefix": "BRIG", "sequence_name": "heron" }, + { "prefix": "BRIS", "sequence_name": "heron" }, + { "prefix": "CAMB", "sequence_name": "heron" }, + { "prefix": "CAMC", "sequence_name": "heron" }, + { "prefix": "CPTD", "sequence_name": "heron" }, + { "prefix": "CWAR", "sequence_name": "heron" }, + { "prefix": "EDIN", "sequence_name": "heron" }, + { "prefix": "EKHU", "sequence_name": "heron" }, + { "prefix": "EXET", "sequence_name": "heron" }, + { "prefix": "GCVR", "sequence_name": "heron" }, + { "prefix": "GLOU", "sequence_name": "heron" }, + { "prefix": "GSTT", "sequence_name": "heron" }, + { "prefix": "HECH", "sequence_name": "heron" }, + { "prefix": "HSLL", "sequence_name": "heron" }, + { "prefix": "KGHT", "sequence_name": "heron" }, + { "prefix": "LCST", "sequence_name": "heron" }, + { "prefix": "LEED", "sequence_name": "heron" }, + { "prefix": "LIVE", "sequence_name": "heron" }, + { "prefix": "LOND", "sequence_name": "heron" }, + { "prefix": "LSPA", "sequence_name": "heron" }, + { "prefix": "MILK", "sequence_name": "heron" }, + { "prefix": "MTUN", "sequence_name": "heron" }, + { "prefix": "NEWC", "sequence_name": "heron" }, + { "prefix": "NIRE", "sequence_name": "heron" }, + { "prefix": "NORT", "sequence_name": "heron" }, + { "prefix": "NORW", "sequence_name": "heron" }, + { "prefix": "NOTT", "sequence_name": "heron" }, + { "prefix": "NWGH", "sequence_name": "heron" }, + { "prefix": "OXON", "sequence_name": "heron" }, + { "prefix": "PAHT", "sequence_name": "heron" }, + { "prefix": "PHEC", "sequence_name": "heron" }, + { "prefix": "PHWC", "sequence_name": "heron" }, + { "prefix": "PLYM", "sequence_name": "heron" }, + { "prefix": "PORT", "sequence_name": "heron" }, + { "prefix": "PRIN", "sequence_name": "heron" }, + { "prefix": "QEUH", "sequence_name": "heron" }, + { "prefix": "RAND", "sequence_name": "heron" }, + { "prefix": "RSCH", "sequence_name": "heron" }, + { "prefix": "SANG", "sequence_name": "heron" }, + { "prefix": "SHEF", "sequence_name": "heron" }, + { "prefix": "TBSD", "sequence_name": "heron" }, + { "prefix": "TFCI", "sequence_name": "heron" }, + { "prefix": "WAHH", "sequence_name": "heron" }, + { "prefix": "WSFT", "sequence_name": "heron" }, + { "prefix": "HT", "sequence_name": "ht" } ] function buildPrefixSelector() { diff --git a/baracoda/types.py b/baracoda/types.py new file mode 100644 index 0000000..87ae168 --- /dev/null +++ b/baracoda/types.py @@ -0,0 +1,17 @@ +from typing import List, Union, TypedDict, Type +from baracoda.formats import HeronCogUkIdFormatter, GenericBarcodeFormatter + +FormatterClassType = Union[Type[HeronCogUkIdFormatter], Type[GenericBarcodeFormatter]] + +PrefixesType = TypedDict( + "PrefixesType", + {"prefix": str, "sequence_name": str, "formatter_class": FormatterClassType, "enableChildrenCreation": bool}, +) + +BarcodeParentInfoType = TypedDict( + "BarcodeParentInfoType", + {"parent_barcode": str, "child": str}, +) + + +FormatterInterfaceType = List[PrefixesType] diff --git a/docker-compose.yml b/docker-compose.yml index 4b82c38..9e3350a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: "3.8" +version: "3.3" services: # A Postgresql service as close to production as currently possible # To run it as a standalone container: diff --git a/tests/conftest.py b/tests/conftest.py index 1e7047c..97dffcc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,7 @@ from baracoda import create_app from baracoda.db import db, reset_db -from baracoda.formats import HeronFormatter +from baracoda.formats import HeronCogUkIdFormatter from tests.data.fixture_data import PREFIXES @@ -40,7 +40,7 @@ def client(app): @pytest.fixture def heron_formatter(): - return HeronFormatter(prefix="SANG") + return HeronCogUkIdFormatter(prefix="SANG") @pytest.fixture diff --git a/tests/data/fixture_data.py b/tests/data/fixture_data.py index bf3c50d..fff82a9 100644 --- a/tests/data/fixture_data.py +++ b/tests/data/fixture_data.py @@ -1,34 +1,47 @@ from typing import Dict, List, Union +from baracoda.formats import HeronCogUkIdFormatter, GenericBarcodeFormatter -PREFIXES: List[Dict[str, Union[str, bool]]] = [ +PREFIXES: List[Dict[str, Union[str, object]]] = [ { "prefix": "SANG", "sequence_name": "heron", - "convert": True, + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, }, { "prefix": "CAMB", "sequence_name": "heron", - "convert": True, + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, }, { "prefix": "NORW", "sequence_name": "heron", - "convert": True, + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, }, { "prefix": "NOTT", "sequence_name": "heron", - "convert": True, + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, }, { "prefix": "LEED", "sequence_name": "heron", - "convert": True, + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, }, { "prefix": "HT", "sequence_name": "ht", - "convert": False, + "formatter_class": GenericBarcodeFormatter, + "enableChildrenCreation": False, + }, + { + "prefix": "SQPD", + "sequence_name": "sqp", + "formatter_class": GenericBarcodeFormatter, + "enableChildrenCreation": True, }, ] diff --git a/tests/test_barcodes.py b/tests/test_barcodes.py index 4011ed7..cae3cd0 100644 --- a/tests/test_barcodes.py +++ b/tests/test_barcodes.py @@ -1,8 +1,10 @@ from http import HTTPStatus +import json # sequences # starts at 2000000 for heron # starts at 111111 for ht +# starts at 1 for sqp def test_param_empty_prefix_value(client): @@ -27,6 +29,12 @@ def test_get_new_barcode_for_ht(client): assert response.status_code == HTTPStatus.CREATED +def test_get_new_barcode_for_sqp(client): + response = client.post("/barcodes/SQPD/new") + assert response.json == {"barcode": "SQPD-1"} + assert response.status_code == HTTPStatus.CREATED + + def test_get_new_barcodes_group_as_url_param(client): response = client.post("/barcodes_group/SANG/new?count=3") assert response.json == {"barcodes_group": {"barcodes": ["SANG-30D404", "SANG-30D413", "SANG-30D422"], "id": 1}} @@ -34,12 +42,18 @@ def test_get_new_barcodes_group_as_url_param(client): def test_get_new_barcodes_group_without_count(client): - response = client.post("/barcodes_group/SANG/new") + # Since the count is no longer a param the get_count_param method looks at the json body + # this response.json method will internal error unless we pass the correct headers and data + response = client.post( + "/barcodes_group/SANG/new", data=json.dumps({}), headers={"Content-Type": "application/json"} + ) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY def test_get_new_barcodes_group_as_json_param(client): - response = client.post("/barcodes_group/SANG/new", data={"count": 2}) + response = client.post( + "/barcodes_group/SANG/new", data=json.dumps({"count": 2}), headers={"Content-Type": "application/json"} + ) assert response.json == {"barcodes_group": {"barcodes": ["SANG-30D404", "SANG-30D413"], "id": 1}} assert response.status_code == HTTPStatus.CREATED diff --git a/tests/test_child_barcodes.py b/tests/test_child_barcodes.py new file mode 100644 index 0000000..6671aa4 --- /dev/null +++ b/tests/test_child_barcodes.py @@ -0,0 +1,329 @@ +from http import HTTPStatus +import json +import pytest + +CHILD_BARCODE_PREFIXES = ["SQPD"] + + +@pytest.mark.parametrize("prefix", CHILD_BARCODE_PREFIXES) +def test_new_child_barcode_can_create_single_barcode(client, prefix): + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": f"{ prefix }-1"}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == {"barcodes_group": {"id": 1, "barcodes": [f"{ prefix }-1-1"]}} + assert response.status_code == HTTPStatus.CREATED + + +@pytest.mark.parametrize("prefix", CHILD_BARCODE_PREFIXES) +def test_new_child_barcode_can_create_several_barcodes(client, prefix): + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": f"{ prefix }-1", "count": 3}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == { + "barcodes_group": {"id": 1, "barcodes": [f"{ prefix }-1-1", f"{ prefix }-1-2", f"{ prefix }-1-3"]} + } + assert response.status_code == HTTPStatus.CREATED + + +@pytest.mark.parametrize("prefix", CHILD_BARCODE_PREFIXES) +def test_new_child_barcode_different_parent_keep_their_own_counting_of_children(client, prefix): + # Parent 1 + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": f"{ prefix }-1", "count": 3}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == { + "barcodes_group": {"id": 1, "barcodes": [f"{ prefix }-1-1", f"{ prefix }-1-2", f"{ prefix }-1-3"]} + } + assert response.status_code == HTTPStatus.CREATED + + # Parent 2 + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": f"{ prefix }-2", "count": 2}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == {"barcodes_group": {"id": 2, "barcodes": [f"{ prefix }-2-1", f"{ prefix }-2-2"]}} + assert response.status_code == HTTPStatus.CREATED + + # Parent 1 again + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": f"{ prefix }-1", "count": 1}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == {"barcodes_group": {"id": 3, "barcodes": [f"{ prefix }-1-4"]}} + assert response.status_code == HTTPStatus.CREATED + + +@pytest.mark.parametrize("prefix", CHILD_BARCODE_PREFIXES) +def test_new_child_barcode_incorrect_valid_prefixed_parent_can_create_unattributed_children(client, prefix): + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": "SANG-1", "count": 3}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == { + "barcodes_group": {"id": 1, "barcodes": [f"{ prefix }-1", f"{ prefix }-2", f"{ prefix }-3"]} + } + assert response.status_code == HTTPStatus.CREATED + + +@pytest.mark.parametrize("prefix", CHILD_BARCODE_PREFIXES) +def test_new_child_barcode_invalid_prefixed_parent_can_create_unattributed_children(client, prefix): + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": "test-123", "count": 3}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == { + "barcodes_group": {"barcodes": [f"{ prefix }-1", f"{ prefix }-2", f"{ prefix }-3"], "id": 1} + } + assert response.status_code == HTTPStatus.CREATED + + +@pytest.mark.parametrize("prefix", CHILD_BARCODE_PREFIXES) +def test_new_child_barcode_valid_parent_can_create_attributed_children_and_descendants_can_continue_lineage( + client, prefix +): + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": f"{ prefix }-1", "count": 3}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == { + "barcodes_group": {"barcodes": [f"{ prefix }-1-1", f"{ prefix }-1-2", f"{ prefix }-1-3"], "id": 1} + } + assert response.status_code == HTTPStatus.CREATED + + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": f"{ prefix }-1-1", "count": 3}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == { + "barcodes_group": {"barcodes": [f"{ prefix }-1-4", f"{ prefix }-1-5", f"{ prefix }-1-6"], "id": 2} + } + assert response.status_code == HTTPStatus.CREATED + + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": f"{ prefix }-1-5", "count": 1}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == {"barcodes_group": {"barcodes": [f"{ prefix }-1-7"], "id": 3}} + assert response.status_code == HTTPStatus.CREATED + + +@pytest.mark.parametrize("prefix", CHILD_BARCODE_PREFIXES) +def test_new_child_barcode_valid_parent_can_create_attributed_children_and_children_and_parent_can_continue_lineage( + client, prefix +): + # Parent + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": f"{ prefix }-1", "count": 3}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == { + "barcodes_group": {"barcodes": [f"{ prefix }-1-1", f"{ prefix }-1-2", f"{ prefix }-1-3"], "id": 1} + } + assert response.status_code == HTTPStatus.CREATED + + # Children 1 + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": f"{ prefix }-1-1", "count": 3}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == { + "barcodes_group": {"barcodes": [f"{ prefix }-1-4", f"{ prefix }-1-5", f"{ prefix }-1-6"], "id": 2} + } + assert response.status_code == HTTPStatus.CREATED + + # Children 2 + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": f"{ prefix }-1-2", "count": 2}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == {"barcodes_group": {"barcodes": [f"{ prefix }-1-7", f"{ prefix }-1-8"], "id": 3}} + assert response.status_code == HTTPStatus.CREATED + + # Parent again + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": f"{ prefix }-1", "count": 2}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == {"barcodes_group": {"barcodes": [f"{ prefix }-1-9", f"{ prefix }-1-10"], "id": 4}} + assert response.status_code == HTTPStatus.CREATED + + +@pytest.mark.parametrize("prefix", CHILD_BARCODE_PREFIXES) +def test_new_child_barcode_invalid_parent_can_create_unattributed_children(client, prefix): + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": f"{ prefix }1-1", "count": 3}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == { + "barcodes_group": {"barcodes": [f"{ prefix }-1", f"{ prefix }-2", f"{ prefix }-3"], "id": 1} + } + assert response.status_code == HTTPStatus.CREATED + + +@pytest.mark.parametrize("prefix", CHILD_BARCODE_PREFIXES) +def test_new_child_barcode_invalid_parent_can_create_unattributed_children_several_times(client, prefix): + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": f"{ prefix }1-1", "count": 3}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == { + "barcodes_group": {"barcodes": [f"{ prefix }-1", f"{ prefix }-2", f"{ prefix }-3"], "id": 1} + } + assert response.status_code == HTTPStatus.CREATED + + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": f"{ prefix }1-1", "count": 3}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == { + "barcodes_group": {"barcodes": [f"{ prefix }-4", f"{ prefix }-5", f"{ prefix }-6"], "id": 2} + } + assert response.status_code == HTTPStatus.CREATED + + +@pytest.mark.parametrize("prefix", CHILD_BARCODE_PREFIXES) +def test_new_child_barcode_unattributed_children_of_invalid_parent_can_start_own_lineage(client, prefix): + # invalid parent + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": f"{ prefix }1-1", "count": 3}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == { + "barcodes_group": {"barcodes": [f"{ prefix }-1", f"{ prefix }-2", f"{ prefix }-3"], "id": 1} + } + assert response.status_code == HTTPStatus.CREATED + + # unattributed_children + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": f"{ prefix }-1", "count": 2}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == {"barcodes_group": {"barcodes": [f"{ prefix }-1-1", f"{ prefix }-1-2"], "id": 2}} + assert response.status_code == HTTPStatus.CREATED + + # new lineage + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": f"{ prefix }-1-2", "count": 2}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == {"barcodes_group": {"barcodes": [f"{ prefix }-1-3", f"{ prefix }-1-4"], "id": 3}} + assert response.status_code == HTTPStatus.CREATED + + +@pytest.mark.parametrize("prefix", CHILD_BARCODE_PREFIXES) +def test_new_child_barcode_impostor_children_with_possible_parent_can_be_stopped(client, prefix): + # invalid parent + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": f"{ prefix }-1", "count": 2}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == {"barcodes_group": {"barcodes": [f"{ prefix }-1-1", f"{ prefix }-1-2"], "id": 1}} + assert response.status_code == HTTPStatus.CREATED + + # Hacking children + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": f"{ prefix }-1-3", "count": 1}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == {"errors": ["InvalidParentBarcode"]} + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + + +@pytest.mark.parametrize("prefix", CHILD_BARCODE_PREFIXES) +def test_new_child_barcode_impostor_children_without_possible_parent_can_be_stopped(client, prefix): + # Hacking children + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": f"{ prefix }-1-3", "count": 1}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == {"errors": ["InvalidParentBarcode"]} + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + + +@pytest.mark.parametrize("prefix", CHILD_BARCODE_PREFIXES) +def test_new_child_barcode_children_of_invalid_parent_can_create_children(client, prefix): + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": f"{ prefix }1-1", "count": 3}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == { + "barcodes_group": {"barcodes": [f"{ prefix }-1", f"{ prefix }-2", f"{ prefix }-3"], "id": 1} + } + assert response.status_code == HTTPStatus.CREATED + + response = client.post( + f"/child-barcodes/{ prefix }/new", + data=json.dumps({"barcode": f"{ prefix }-1", "count": 3}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == { + "barcodes_group": {"barcodes": [f"{ prefix }-1-1", f"{ prefix }-1-2", f"{ prefix }-1-3"], "id": 2} + } + assert response.status_code == HTTPStatus.CREATED + + +@pytest.mark.parametrize("prefix", CHILD_BARCODE_PREFIXES) +def test_new_child_barcode_with_unknown_prefix_rejects_request(client, prefix): + response = client.post( + "/child-barcodes/unknown/new", + data=json.dumps({"barcode": "SANG-1", "count": 3}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == {"errors": ["InvalidPrefixError"]} + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + + +@pytest.mark.parametrize("prefix", CHILD_BARCODE_PREFIXES) +def test_no_child_barcode(client, prefix): + response = client.post( + "/child-barcodes/test/new", data=json.dumps({}), headers={"Content-Type": "application/json"} + ) + assert response.json == {"errors": ["InvalidBarcodeError"]} + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +@pytest.mark.parametrize("prefix", CHILD_BARCODE_PREFIXES) +def test_bad_child_barcode(client, prefix): + response = client.post( + "/child-barcodes/test/new", data=json.dumps({"barcode": " "}), headers={"Content-Type": "application/json"} + ) + assert response.json == {"errors": ["InvalidBarcodeError"]} + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +@pytest.mark.parametrize("prefix", CHILD_BARCODE_PREFIXES) +def test_bad_count(client, prefix): + response = client.post( + "/child-barcodes/test/new", + data=json.dumps({"barcode": "test", "count": 0}), + headers={"Content-Type": "application/json"}, + ) + assert response.json == {"errors": ["InvalidCountError"]} + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY diff --git a/tests/test_formats.py b/tests/test_formats.py index cac5ba4..c0a1825 100644 --- a/tests/test_formats.py +++ b/tests/test_formats.py @@ -1,4 +1,4 @@ -from baracoda.formats import HeronFormatter +from baracoda.formats import HeronCogUkIdFormatter, GenericBarcodeFormatter def test_checksum_conversion(heron_formatter): @@ -19,15 +19,20 @@ def test_barcode_example_1(heron_formatter): def test_barcode_example_2(): - formatter = HeronFormatter(prefix="NIRE") + formatter = HeronCogUkIdFormatter(prefix="NIRE") assert formatter.barcode(111111) == "NIRE-1B2075" -def test_barcode_example_when_no_conversion_needed(): - formatter = HeronFormatter(prefix="HT", convert=False) +def test_barcode_example_plate_cherrypicked(): + formatter = GenericBarcodeFormatter(prefix="HT") assert formatter.barcode(111111) == "HT-111111" +def test_barcode_example_plate_sequencescape(): + formatter = GenericBarcodeFormatter(prefix="SQPD") + assert formatter.barcode(1) == "SQPD-1" + + def barcode_for(barcode: str) -> str: """Exract the prefix and checksum from a given barcode then recreates the barcode given the number and prefix. @@ -40,7 +45,7 @@ def barcode_for(barcode: str) -> str: """ prefix, number_and_checksum = barcode.split("-") number = number_and_checksum[:-1] - formatter = HeronFormatter(prefix=prefix) + formatter = HeronCogUkIdFormatter(prefix=prefix) return formatter.barcode(int(number, 16)) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index f145da7..100a71b 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,10 +1,16 @@ +from baracoda.formats import HeronCogUkIdFormatter from baracoda.helpers import get_prefix_item def test_correct_prefix_item_is_returned(app, prefixes): with app.app_context(): prefix_item = get_prefix_item("LEED") - assert prefix_item == {"prefix": "LEED", "sequence_name": "heron", "convert": True} + assert prefix_item == { + "prefix": "LEED", + "sequence_name": "heron", + "formatter_class": HeronCogUkIdFormatter, + "enableChildrenCreation": False, + } def test_none_is_returned_for_invalid_prefix(app): diff --git a/tests/test_operations.py b/tests/test_operations.py index c156b78..b3971a2 100644 --- a/tests/test_operations.py +++ b/tests/test_operations.py @@ -2,7 +2,10 @@ from baracoda.exceptions import InvalidPrefixError from baracoda.helpers import get_prefix_item -from baracoda.operations import BarcodeOperations +from baracoda.operations import BarcodeOperations, InvalidParentBarcode + + +# BarcodeOperations def test_correct_prefix_obj_is_created(app, prefixes): @@ -23,7 +26,80 @@ def test_sequence_is_correct_for_ht_plates(app): assert barcode_operations.sequence_name == "ht" +def test_sequence_is_correct_for_SQPD_plates(app): + with app.app_context(): + barcode_operations = BarcodeOperations(prefix="SQPD") + assert barcode_operations.sequence_name == "sqp" + + def test_error_is_raised_if_prefix_is_not_valid(app): with app.app_context(): with pytest.raises(InvalidPrefixError): _ = BarcodeOperations(prefix="MOON") + + +# Child barcode operations + + +def test_child_barcodes_are_created_when_new_barcode(app): + with app.app_context(): + barcode_operations = BarcodeOperations(prefix="SQPD") + expected_child_barcodes = ["SQPD-1"] + assert barcode_operations.create_child_barcodes("SQPD", 1) == expected_child_barcodes + + +def test_child_barcodes_are_created_when_existing_barcode(app): + with app.app_context(): + # Create a barcode record in the database + barcode_operations = BarcodeOperations(prefix="SQPD") + barcode_operations.create_child_barcodes("SQPD", 5) + # Expect child barcode to have correct when suffix when same barcode is used + expected_child_barcodes = ["SQPD-6"] + assert barcode_operations.create_child_barcodes("SQPD", 1) == expected_child_barcodes + + +def test_correct_number_of_child_barcodes_are_created(app): + with app.app_context(): + barcode_operations = BarcodeOperations(prefix="SQPD") + expected_child_barcodes = ["SQPD-1", "SQPD-2", "SQPD-3"] + assert barcode_operations.create_child_barcodes("SQPD", 3) == expected_child_barcodes + + +def test_is_valid_parent_barcode(app): + with app.app_context(): + barcode_operations = BarcodeOperations(prefix="HT") + assert barcode_operations.is_valid_parent_barcode("HT-1234") is True + assert barcode_operations.is_valid_parent_barcode("SQPD-1234") is False + assert barcode_operations.is_valid_parent_barcode("HT-1234-1") is True + assert barcode_operations.is_valid_parent_barcode("HT-1234-1-1") is False + assert barcode_operations.is_valid_parent_barcode("HT-1234-1-1-1") is False + assert barcode_operations.is_valid_parent_barcode("HT1234") is False + assert barcode_operations.is_valid_parent_barcode("HT") is False + assert barcode_operations.is_valid_parent_barcode("HT-") is False + assert barcode_operations.is_valid_parent_barcode("") is False + + +def test_validate_barcode_parent_information(app): + with app.app_context(): + barcode_operations = BarcodeOperations(prefix="SQPD") + with pytest.raises(InvalidParentBarcode): + barcode_operations.validate_barcode_parent_information({"parent_barcode": "SQPD-1", "child": "1"}) + + +def test_extract_barcode_parent_information(app): + with app.app_context(): + barcode_operations = BarcodeOperations(prefix="SQPD") + + assert barcode_operations.extract_barcode_parent_information("SQPD-1") == { + "parent_barcode": "SQPD-1", + "child": None, + } + + assert barcode_operations.extract_barcode_parent_information("SQPD-11-22") == { + "parent_barcode": "SQPD-11", + "child": "22", + } + + assert barcode_operations.extract_barcode_parent_information("SQPD-11-22-33") is None + + assert barcode_operations.extract_barcode_parent_information("DN-11-22") is None