diff --git a/.env b/.env new file mode 100644 index 00000000..04745946 --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +# DO NOT COMMIT THIS FILE + +BCSS_PASS= diff --git a/.github/actions/run-playwright-tests/action.yml b/.github/actions/run-playwright-tests/action.yml index 03cf714c..bfa58d82 100644 --- a/.github/actions/run-playwright-tests/action.yml +++ b/.github/actions/run-playwright-tests/action.yml @@ -1,6 +1,17 @@ -name: "Run Util & Example Tests" +name: "Run Playwright Tests" runs-on: ubuntu-latest -timeout-minutes: 3 +timeout-minutes: 10 + +inputs: + bcss_cloud_environment: + description: "The environment to run against in lower case (e.g. bcss-1234)" + required: true + type: string + marker_to_use: + description: "The test marker to use when running tests (e.g. smoke)" + required: true + type: string + runs: using: "composite" steps: @@ -16,18 +27,12 @@ runs: - name: Ensure browsers are installed shell: bash run: python -m playwright install --with-deps - - name: Run util tests + - name: Run specified tests shell: bash - run: pytest -m "utils" --ignore=tests/ - - uses: actions/upload-artifact@v4 - if: ${{ !cancelled() }} - with: - name: result-output-utils - path: test-results/ - retention-days: 3 - - name: Run example tests - shell: bash - run: pytest --ignore=tests_utils/ + run: | + URL_TO_USE="${URL_TO_USE_DEFAULT///${{ inputs.bcss_cloud_environment }}}" + ORACLE_DB="${ORACLE_DB_DEFAULT///${{ inputs.bcss_cloud_environment }}}" + pytest -m ${{ inputs.marker_to_use }} --base-url=https://$URL_TO_USE --ignore=tests_utils/ - uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: diff --git a/.github/actions/run-util-tests/action.yml b/.github/actions/run-util-tests/action.yml new file mode 100644 index 00000000..fbf4bdcd --- /dev/null +++ b/.github/actions/run-util-tests/action.yml @@ -0,0 +1,25 @@ +name: "Run Util Tests" +runs-on: ubuntu-latest +timeout-minutes: 3 +runs: + using: "composite" + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Install dependencies + shell: bash + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Run util tests + shell: bash + run: pytest -m "utils" --ignore=tests/ + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: result-output-utils + path: test-results/ + retention-days: 3 + diff --git a/.github/workflows/execute-tests.yaml b/.github/workflows/execute-tests.yaml new file mode 100644 index 00000000..05b82c2e --- /dev/null +++ b/.github/workflows/execute-tests.yaml @@ -0,0 +1,40 @@ +name: "Test Runner" + +# This workflow is triggered manually and allows the user to specify the environment and test marker to run. +# It is functional, however will not work against BCSS test environments until we configure some self-hosted +# GitHub runners, as the GitHub-hosted runners are based outside of the UK so get blocked by the WAF on the +# environments. + +on: + workflow_dispatch: + inputs: + bcss_cloud_environment: + description: "The environment to run against in lower case (e.g. bcss-1234)" + required: true + type: string + default: "bcss-18680" + marker_to_use: + description: "The test marker to use when running tests (e.g. smokescreen)" + required: true + type: string + +jobs: + run-tests: + name: "Run Specified Tests" + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: "Checkout code" + uses: actions/checkout@v4 + - name: "Run Tests" + id: run-tests + uses: ./.github/actions/run-playwright-tests + with: + bcss_cloud_environment: "${{ inputs.bcss_cloud_environment }}" + marker_to_use: "${{ inputs.marker_to_use }}" + env: + BCSS_PASS: ${{ secrets.BCSS_PASS }} + ORACLE_DB_DEFAULT: ${{ secrets.ORACLE_DB }} + ORACLE_USERNAME: ${{ secrets.ORACLE_USERNAME }} + ORACLE_PASS: ${{ secrets.ORACLE_PASS }} + URL_TO_USE_DEFAULT: ${{ vars.CLOUD_ENV_URL }} diff --git a/.github/workflows/stage-2-test.yaml b/.github/workflows/stage-2-test.yaml index 48a47ca6..36cf6848 100644 --- a/.github/workflows/stage-2-test.yaml +++ b/.github/workflows/stage-2-test.yaml @@ -30,11 +30,11 @@ on: jobs: run-tests: - name: "Run Util & Example Tests" + name: "Run Util Tests" runs-on: ubuntu-latest timeout-minutes: 3 steps: - name: "Checkout code" uses: actions/checkout@v4 - - name: "Run Playwright Tests" - uses: ./.github/actions/run-playwright-tests + - name: "Run Util Tests" + uses: ./.github/actions/run-util-tests diff --git a/.gitignore b/.gitignore index cf6c86b8..bbde6918 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ __pycache__/ .pytest_cache/ test-results/ axe-reports/ +.env local.env # Please, add your custom content below! diff --git a/.vscode/settings.json b/.vscode/settings.json index 9ccb82cc..de47d16e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,6 +7,8 @@ "cSpell.words": [ "addopts", "autouse", + "BCSS", + "behaviour", "codegen", "customisable", "customised", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c739dffd..8d308552 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,40 +1,175 @@ # Contributing To This Project -With this project, we actively encourage anyone who may have any ideas or code that could make this repository better to contribute -in any way they can. +This document outlines the general guidance that should be applied when contributing new code to this project, +to ensure that coding standards and principles remain consistent throughout the project. -## How To Contribute +## Table of Contents -If you have an idea about something new that could be added, then please raise a -[Feature Request via the Issues tab](https://github.com/nhs-england-tools/playwright-python-blueprint/issues/new/choose) for this -repository. Even if you don't feel you have the technical ability to implement the request, please raise an issue regardless as -the maintainers of this project will frequently review any requests and attempt to implement if deemed suitable for this blueprint. +- [Contributing To This Project](#contributing-to-this-project) + - [Table of Contents](#table-of-contents) + - [General Principles](#general-principles) + - [Use Playwright and pytest documentation as our standard](#use-playwright-and-pytest-documentation-as-our-standard) + - [Proving tests work before raising a pull request](#proving-tests-work-before-raising-a-pull-request) + - [Evidencing tests](#evidencing-tests) + - [Example](#example) + - [Coding Practices](#coding-practices) + - [Tests](#tests) + - [Docstring](#docstring) + - [Example](#example-1) + - [Page objects](#page-objects) + - [Naming Conventions](#naming-conventions) + - [Docstring](#docstring-1) + - [Example](#example-2) + - [Utilities](#utilities) + - [Docstring](#docstring-2) + - [Example](#example-3) + - [Package management](#package-management) + - [Last Reviewed](#last-reviewed) -If you have some code you think could be implemented, please raise a Feature Request and -[create a fork of this repository](https://github.com/nhs-england-tools/playwright-python-blueprint/fork) to experiment and ensure -that the change you want to push back works as intended. +## General Principles -## Contribution Requirements +### Use Playwright and pytest documentation as our standard -For any contributions to this project, the following requirements need to be met: +When contributing to this project, we should be following the guidance outlined in the +[Playwright](https://playwright.dev/python/docs/api/class-playwright) and +[pytest](https://docs.pytest.org/en/stable/) +documentation in the first instance to ensure our code remains as close to the recommended standard as possible. +This will allow anyone contributing to this project to follow the code and when we use elements from either +Playwright or pytest, easily reference the implementation from their documentation. -- You must be a member of the [NHS England Tools](https://github.com/nhs-england-tools) organisation on GitHub. -- [Any commits must be signed](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits), so they show as verified once they reach GitHub. This checking serves as part of our CI/CD process, so unsigned commits will prevent a pull request from being merged. -- For any utility methods that are added to this framework in the `utils` directory, the following applies: - - Unit tests for the utility should be added to the `tests_utils` directory and need to be tagged with the `utils` mark. - - Documentation for these classes and how to use any methods should also be added to the `docs/utilities-guide` directory. - - Each method that is intended to be used as part of a class should have a correctly formatted docstring, to allow for developers using Intellisense within their IDE to understand what the code is intended to do. -- All CI/CD checks will need to pass before any code is merged to the `main` branch - this includes ensuring appropriate formatting of code and documentation, security checks and that all unit and example tests pass. +In the event we need to deviate away from this for any reason, we should clearly document the reasons why and explain +this in any pull request raised. -## Things We Want +### Proving tests work before raising a pull request -What we are particularly interested in is: +When creating or modifying any code, if tests in the framework have been impacted by the change we should ensure that +we execute the tests prior to raising a pull request (to ensure we are not asking for a code review for code that does +not work as intended). This can either be done locally or via a pipeline/workflow. -- Any utility classes that can uniformly applied to any project. This may be something that's been created for your own project and by doing some minor abstraction any other teams working in a similar way could adopt this functionality. -- Any development code that supports executing this project in a CI/CD fashion. This primarily covers any changes that support development principles outlined in the [Software Engineering Quality Framework](https://github.com/NHSDigital/software-engineering-quality-framework), and could include logic around how the test code is containerized. -- Any changes that allow for test reporting in a consistent, reliable, maintainable and interesting format for varying stakeholders. This includes logic that expands on from the reporting we generate, such as example scripts for how to generate dashboards using the data we generate. +### Evidencing tests -Examples: +When we create new tests, or significantly change the functionality of a test, we should demonstrate these tests work +by [recording the trace using Playwright](https://playwright.dev/python/docs/trace-viewer-intro) and attaching the +generated trace file to the associated ticket within `Jira`. -- Say you've created a utility for generating test patients within your application. Any elements of this code that could be universally applied and other teams are likely to use (e.g. NHS number, patient name) we would want in this blueprint. If there's something business specific to your project that exists as part of this code (e.g. a unique reference number that only applies to your service), then we would advise removing that logic from any code before raising a pull request here. - - If you do end up adding a utility class in this format in a more generic way to this project, you can subsequently [inherit the utility class](https://docs.python.org/3/tutorial/classes.html#inheritance) to include your additional business-specific requirements within your own version of the class. +> NOTE: If the trace file exceeds the maximum file attachment size for `Jira`, we should +> upload the file to a Confluence page instead and link this back to the ticket. + +When we modify existing tests (including any page objects or utilities used by these tests) but the behaviour of the test +has not fundamentally changed, we should upload the generated HTML report from the Playwright execution to the +`Jira` ticket. + +#### Example + +We introduce a new test that covers the send a kit functionality for a single subject using `codegen` in the first instance. +To demonstrate this test has worked as intended, we should turn tracing on and generate a trace file from Playwright and +attach this to the ticket in `Jira`. + +We then decide to refactor the test so that it uses a page object model for the send a kit page, but this does not change +the behaviour of the test in any way (just makes the elements reusable). In this instance, we should upload the HTML report +to the `Jira` ticket showing the test passing as the logic of the test has not changed in any way. + +We then decide to create a utility that loops through several subjects at once and apply this to the previously created send +a kit test. In this instance, we should turn tracing on again and generate a trace file from Playwright and attach this to the +ticket in `Jira`, because the logic of the test has fundamentally changed. + +## Coding Practices + +### Tests + +The following guidance applies to any files in the /tests directory. + +#### Docstring + +For any tests in the project, we should add a docstring that explains the test in a non-technical way, outlining the following +points: + +- The steps undertaken as part of the test +- References to any applicable acceptance criteria this test covers + +This information will be populated on the HTML report provided by the framework, allowing for non-technical stakeholders to +understand the purpose of a test without specifically needing to view the code directly. + +This should always be done using a [multi-line docstring](https://peps.python.org/pep-0257/#multi-line-docstrings), even if +the test description is reasonably short. + +##### Example + + def test_example_scenario(page: Page) -> None: + """ + This test covers an example scenario whereby the user navigates to the subject search page, + selects a subject who is 70 years old and validates their age is correctly displayed on the + screening subject summary page. + + This test is used for the following acceptance criteria: + - BCSS-1234 (A/C 1) + """ + +### Page objects + +The following guidance applies to any files in the /pages directory. + +#### Naming Conventions + +For any newly created page objects, we should apply the following logic: + +- The filename should end with `_page` (Example: `send_a_kit_page.py`) +- The class name should end with `Page` (Example: `SendAKitPage`) + +#### Docstring + +For any page objects in the project, we need to ensure for any class methods or functions we give a +brief description of the intent of the function for the benefit of anyone reading the project or using +intellisense. This can be done using a [single-line docstring](https://peps.python.org/pep-0257/#one-line-docstrings) +where possible. + +##### Example + + class ExamplePage: + + def __init__(page: Page) -> None: + self.page = page + + def click_on_locator(locator: Locator) -> None: + """Clicks on the provided locator.""" + locator.click() + + def get_text_from_locator(locator: Locator) -> str: + """Returns the text from the locator as a string.""" + return locator.inner_text() + +### Utilities + +The following guidance applies to any files in the /utils directory. + +#### Docstring + +For any utilities added to the project, we should add docstrings that outline the functionality including +any arguments and returns. These should be formatted as +[multi-line docstrings](https://peps.python.org/pep-0257/#multi-line-docstrings) with a description, Args and +Returns provided. + +##### Example + + def example_util(user: str) -> dict: + """ + Takes the user string and retrieves example information applicable to this user. + + Args: + user (str): The user details required, using the record key from users.json. + + Returns: + dict: A Python dictionary with the example details of the user requested. + """ + +### Package management + +If we need to introduce a new Python package (via requirements.txt) into this project to allow for +appropriate testing, we need to have critically reviewed the package and ensure the following: + +- The package we intend to use is actively being maintained (e.g. has had recent updates or has a large active community behind it) +- The package has appropriate documentation that will allow us to easily implement and maintain the dependency in our code + +## Last Reviewed + +This document was last reviewed on 01/05/2025. diff --git a/classes/address_contact_type.py b/classes/address_contact_type.py new file mode 100644 index 00000000..04f42ef1 --- /dev/null +++ b/classes/address_contact_type.py @@ -0,0 +1,64 @@ +from enum import Enum +from typing import Optional + + +class AddressContactType(Enum): + """ + Enum representing the type of address contact for a subject. + + Attributes: + WORK: Represents a work address contact type. + HOME: Represents a home address contact type. + """ + + WORK = (13056, "WORK") + HOME = (13057, "HOME") + + def __init__(self, valid_value_id: int, allowed_value: str): + """ + Initialize an AddressContactType enum member. + + Args: + valid_value_id (int): The unique identifier for the address contact type. + allowed_value (str): The string representation of the address contact type. + """ + self._valid_value_id = valid_value_id + self._allowed_value = allowed_value + + @property + def valid_value_id(self) -> int: + """ + Returns the unique identifier for the address contact type. + + Returns: + int: The valid value ID. + """ + return self._valid_value_id + + @property + def allowed_value(self) -> str: + """ + Returns the string representation of the address contact type. + + Returns: + str: The allowed value. + """ + return self._allowed_value + + @classmethod + def by_valid_value_id( + cls, address_contact_type_id: int + ) -> Optional["AddressContactType"]: + """ + Returns the AddressContactType enum member matching the given valid value ID. + + Args: + address_contact_type_id (int): The valid value ID to search for. + + Returns: + Optional[AddressContactType]: The matching enum member, or None if not found. + """ + return next( + (item for item in cls if item.valid_value_id == address_contact_type_id), + None, + ) diff --git a/classes/address_type.py b/classes/address_type.py new file mode 100644 index 00000000..cde36283 --- /dev/null +++ b/classes/address_type.py @@ -0,0 +1,61 @@ +from enum import Enum +from typing import Optional + + +class AddressType(Enum): + """ + Enum representing the type of address for a subject. + + Attributes: + MAIN_REGISTERED_ADDRESS: Represents the main registered address (code "H"). + TEMPORARY_ADDRESS: Represents a temporary address (code "T"). + """ + + MAIN_REGISTERED_ADDRESS = (13042, "H") + TEMPORARY_ADDRESS = (13043, "T") + + def __init__(self, valid_value_id: int, allowed_value: str): + """ + Initialize an AddressType enum member. + + Args: + valid_value_id (int): The unique identifier for the address type. + allowed_value (str): The string representation of the address type. + """ + self._valid_value_id = valid_value_id + self._allowed_value = allowed_value + + @property + def valid_value_id(self) -> int: + """ + Returns the unique identifier for the address type. + + Returns: + int: The valid value ID. + """ + return self._valid_value_id + + @property + def allowed_value(self) -> str: + """ + Returns the string representation of the address type. + + Returns: + str: The allowed value. + """ + return self._allowed_value + + @classmethod + def by_valid_value_id(cls, address_type_id: int) -> Optional["AddressType"]: + """ + Returns the AddressType enum member matching the given valid value ID. + + Args: + address_type_id (int): The valid value ID to search for. + + Returns: + Optional[AddressType]: The matching enum member, or None if not found. + """ + return next( + (item for item in cls if item.valid_value_id == address_type_id), None + ) diff --git a/classes/appointment_status_type.py b/classes/appointment_status_type.py new file mode 100644 index 00000000..167ec255 --- /dev/null +++ b/classes/appointment_status_type.py @@ -0,0 +1,39 @@ +class AppointmentStatusType: + """ + Utility class for mapping descriptive appointment statuses to internal IDs. + + This class provides a mapping between human-readable appointment status descriptions + (such as "booked", "attended", "cancelled", "dna") and their corresponding internal + integer IDs used in the system. + + Methods: + get_id(description: str) -> int: + Returns the internal ID for a given appointment status description. + Raises ValueError if the description is not recognized. + """ + + _mapping = { + "booked": 2001, + "attended": 2002, + "cancelled": 2003, + "dna": 2004, # Did Not Attend + } + + @classmethod + def get_id(cls, description: str) -> int: + """ + Returns the internal ID for a given appointment status description. + + Args: + description (str): The appointment status description (e.g., "booked"). + + Returns: + int: The internal ID corresponding to the description. + + Raises: + ValueError: If the description is not recognized. + """ + key = description.strip().lower() + if key not in cls._mapping: + raise ValueError(f"Unknown appointment status: {description}") + return cls._mapping[key] diff --git a/classes/appointments_slot_type.py b/classes/appointments_slot_type.py new file mode 100644 index 00000000..100a09bb --- /dev/null +++ b/classes/appointments_slot_type.py @@ -0,0 +1,38 @@ +class AppointmentSlotType: + """ + Utility class for mapping symbolic appointment slot types to their internal IDs. + + This class provides a mapping between human-readable appointment slot type descriptions + (such as "clinic", "phone", "video") and their corresponding internal integer IDs used in the system. + + Methods: + get_id(description: str) -> int: + Returns the internal ID for a given appointment slot type description. + Raises ValueError if the description is not recognized. + """ + + _mapping = { + "clinic": 1001, + "phone": 1002, + "video": 1003, + # Add more mappings here as needed + } + + @classmethod + def get_id(cls, description: str) -> int: + """ + Returns the internal ID for a given appointment slot type description. + + Args: + description (str): The appointment slot type description (e.g., "clinic"). + + Returns: + int: The internal ID corresponding to the description. + + Raises: + ValueError: If the description is not recognized. + """ + key = description.strip().lower() + if key not in cls._mapping: + raise ValueError(f"Unknown appointment slot type: {description}") + return cls._mapping[key] diff --git a/classes/bowel_scope_dd_reason_for_change_type.py b/classes/bowel_scope_dd_reason_for_change_type.py new file mode 100644 index 00000000..35d46cf6 --- /dev/null +++ b/classes/bowel_scope_dd_reason_for_change_type.py @@ -0,0 +1,126 @@ +from enum import Enum +from typing import Optional, Dict + + +class BowelScopeDDReasonForChangeType(Enum): + """ + Enum representing reasons for change to Bowel Scope Due Date. + + Attributes: + Ceased: Ceased + DateOfBirthAmendment: Date of birth amendment + EligibleToBeInvitedForFsScreening: Eligible to be invited for FS Screening + FsScreeningEpisodeOpened: FS Screening episode opened + MultipleDateOfBirthChanges: Multiple Date of Birth Changes + NoLongerEligibleToBeInvitedForFsScreening: No longer eligible to be invited for FS Screening + ReopenedFsEpisode: Reopened FS Episode + SeekingFurtherData: Seeking further data + """ + + Ceased = (200669, "Ceased") + DateOfBirthAmendment = (205266, "Date of birth amendment") + EligibleToBeInvitedForFsScreening = ( + 200685, + "Eligible to be invited for FS Screening", + ) + FsScreeningEpisodeOpened = (205002, "FS Screening episode opened") + MultipleDateOfBirthChanges = (202426, "Multiple Date of Birth Changes") + NoLongerEligibleToBeInvitedForFsScreening = ( + 200668, + "No longer eligible to be invited for FS Screening", + ) + ReopenedFsEpisode = (205012, "Reopened FS Episode") + SeekingFurtherData = (200670, "Seeking further data") + + def __init__(self, valid_value_id: int, description: str): + """ + Initialize a BowelScopeDDReasonForChangeType enum member. + + Args: + valid_value_id (int): The unique identifier for the reason. + description (str): The string description of the reason. + """ + self._valid_value_id: int = valid_value_id + self._description: str = description + + @property + def valid_value_id(self) -> int: + """ + Returns the unique identifier for the reason. + + Returns: + int: The valid value ID. + """ + return self._valid_value_id + + @property + def description(self) -> str: + """ + Returns the string description of the reason. + + Returns: + str: The description. + """ + return self._description + + @classmethod + def _descriptions(cls) -> Dict[str, "BowelScopeDDReasonForChangeType"]: + """ + Returns a mapping from description to enum member. + + Returns: + Dict[str, BowelScopeDDReasonForChangeType]: Mapping from description to enum member. + """ + return {item.description: item for item in cls} + + @classmethod + def _lowercase_descriptions(cls) -> Dict[str, "BowelScopeDDReasonForChangeType"]: + """ + Returns a mapping from lowercase description to enum member. + + Returns: + Dict[str, BowelScopeDDReasonForChangeType]: Mapping from lowercase description to enum member. + """ + return {item.description.lower(): item for item in cls} + + @classmethod + def _valid_value_ids(cls) -> Dict[int, "BowelScopeDDReasonForChangeType"]: + """ + Returns a mapping from valid value ID to enum member. + + Returns: + Dict[int, BowelScopeDDReasonForChangeType]: Mapping from valid value ID to enum member. + """ + return {item.valid_value_id: item for item in cls} + + @classmethod + def by_description( + cls, description: Optional[str] + ) -> Optional["BowelScopeDDReasonForChangeType"]: + """ + Returns the enum member matching the given description (case-insensitive). + + Args: + description (Optional[str]): The description to search for. + + Returns: + Optional[BowelScopeDDReasonForChangeType]: The matching enum member, or None if not found. + """ + if description is None: + return None + return cls._lowercase_descriptions().get(description.lower()) + + @classmethod + def by_valid_value_id( + cls, valid_value_id: int + ) -> Optional["BowelScopeDDReasonForChangeType"]: + """ + Returns the enum member matching the given valid value ID. + + Args: + valid_value_id (int): The valid value ID to search for. + + Returns: + Optional[BowelScopeDDReasonForChangeType]: The matching enum member, or None if not found. + """ + return cls._valid_value_ids().get(valid_value_id) diff --git a/classes/ceased_confirmation_details.py b/classes/ceased_confirmation_details.py new file mode 100644 index 00000000..4bc5a3cf --- /dev/null +++ b/classes/ceased_confirmation_details.py @@ -0,0 +1,40 @@ +from enum import Enum +from typing import Optional + + +class CeasedConfirmationDetails(Enum): + """ + Enum representing ceased confirmation details for a subject. + + Members: + NULL: Represents a null ceased confirmation detail. + NOT_NULL: Represents a non-null ceased confirmation detail. + """ + + NULL = "null" + NOT_NULL = "not null" + + @classmethod + def by_description(cls, description: str) -> Optional["CeasedConfirmationDetails"]: + """ + Returns the enum member matching the given description. + + Args: + description (str): The description to search for. + + Returns: + Optional[CeasedConfirmationDetails]: The matching enum member, or None if not found. + """ + for item in cls: + if item.value == description: + return item + return None + + def get_description(self) -> str: + """ + Returns the string description of the ceased confirmation detail. + + Returns: + str: The description value. + """ + return self.value diff --git a/classes/ceased_confirmation_user_id.py b/classes/ceased_confirmation_user_id.py new file mode 100644 index 00000000..801f47ae --- /dev/null +++ b/classes/ceased_confirmation_user_id.py @@ -0,0 +1,44 @@ +from enum import Enum +from typing import Optional, Dict + + +class CeasedConfirmationUserId(Enum): + """ + Enum representing possible user IDs for ceased confirmation actions. + + Members: + AUTOMATED_PROCESS_ID: Represents an automated process user ID. + NULL: Represents a null user ID. + NOT_NULL: Represents a non-null user ID. + USER_ID: Represents a specific user's ID. + """ + + AUTOMATED_PROCESS_ID = "automated process id" + NULL = "null" + NOT_NULL = "not null" + USER_ID = "user's id" + + @classmethod + def by_description(cls, description: str) -> Optional["CeasedConfirmationUserId"]: + """ + Returns the enum member matching the given description. + + Args: + description (str): The description to search for. + + Returns: + Optional[CeasedConfirmationUserId]: The matching enum member, or None if not found. + """ + for item in cls: + if item.value == description: + return item + return None + + def get_description(self) -> str: + """ + Returns the string description of the ceased confirmation user ID. + + Returns: + str: The description value. + """ + return self.value diff --git a/classes/clinical_cease_reason_type.py b/classes/clinical_cease_reason_type.py new file mode 100644 index 00000000..c7ba7572 --- /dev/null +++ b/classes/clinical_cease_reason_type.py @@ -0,0 +1,141 @@ +from enum import Enum +from typing import Optional, Dict + + +class ClinicalCeaseReasonType(Enum): + """ + Enum representing clinical reasons for ceasing a subject from screening. + + Members: + ALREADY_INVOLVED_IN_SURVEILLANCE_PROGRAMME_OUTSIDE_BCSP: Already involved in Surveillance Programme outside BCSP + CLINICAL_ASSESSMENT_INDICATES_CEASE_SURVEILLANCE_AND_SCREENING: Clinical Assessment indicates Cease Surveillance and Screening + CURRENTLY_UNDER_TREATMENT: Currently under treatment + NO_FUNCTIONING_COLON: No functioning colon + RECENT_COLONOSCOPY: Recent colonoscopy + REFER_TO_SYMPTOMATIC_SERVICE: Refer to Symptomatic Service + TERMINAL_ILLNESS: Terminal Illness + UNDER_TREATMENT_FOR_ULCERATIVE_COLITIS_CROHNS_DISEASE_BOWEL_CANCER_OR_OTHER_RESULTING_IN_CEASE: Under treatment for ulcerative colitis, Crohn's disease, bowel cancer or other, resulting in cease + UNFIT_FOR_FURTHER_INVESTIGATION: Unfit for further investigation + NULL: Null value for subject selection criteria + NOT_NULL: Not Null value for subject selection criteria + """ + + # BCSS values + ALREADY_INVOLVED_IN_SURVEILLANCE_PROGRAMME_OUTSIDE_BCSP = ( + 11369, + "Already involved in Surveillance Programme outside BCSP", + ) + CLINICAL_ASSESSMENT_INDICATES_CEASE_SURVEILLANCE_AND_SCREENING = ( + 20066, + "Clinical Assessment indicates Cease Surveillance and Screening", + ) + CURRENTLY_UNDER_TREATMENT = (11371, "Currently under treatment") + NO_FUNCTIONING_COLON = (11366, "No functioning colon") + RECENT_COLONOSCOPY = (11372, "Recent colonoscopy") + REFER_TO_SYMPTOMATIC_SERVICE = (205274, "Refer to Symptomatic Service") + TERMINAL_ILLNESS = (11367, "Terminal Illness") + UNDER_TREATMENT_FOR_ULCERATIVE_COLITIS_CROHNS_DISEASE_BOWEL_CANCER_OR_OTHER_RESULTING_IN_CEASE = ( + 11368, + "Under treatment for ulcerative colitis, Crohn's disease, bowel cancer or other, resulting in cease", + ) + UNFIT_FOR_FURTHER_INVESTIGATION = (11373, "Unfit for further investigation") + + # Extra subject selection criteria values + NULL = (None, "Null") + NOT_NULL = (None, "Not Null") + + def __init__(self, valid_value_id: Optional[int], description: str): + """ + Initialize a ClinicalCeaseReasonType enum member. + + Args: + valid_value_id (Optional[int]): The unique identifier for the reason. + description (str): The string description of the reason. + """ + self._valid_value_id = valid_value_id + self._description = description + + @property + def valid_value_id(self) -> Optional[int]: + """ + Returns the unique identifier for the clinical cease reason. + + Returns: + Optional[int]: The valid value ID. + """ + return self._valid_value_id + + @property + def description(self) -> str: + """ + Returns the string description of the clinical cease reason. + + Returns: + str: The description. + """ + return self._description + + @classmethod + def _build_lookup_maps(cls): + """ + Builds lookup maps for descriptions and valid value IDs. + """ + cls._descriptions: Dict[str, "ClinicalCeaseReasonType"] = {} + cls._lowercase_descriptions: Dict[str, "ClinicalCeaseReasonType"] = {} + cls._valid_value_ids: Dict[int, "ClinicalCeaseReasonType"] = {} + + for member in cls: + if member.description: + cls._descriptions[member.description] = member + cls._lowercase_descriptions[member.description.lower()] = member + if member.valid_value_id is not None: + cls._valid_value_ids[member.valid_value_id] = member + + @classmethod + def by_description(cls, description: str) -> Optional["ClinicalCeaseReasonType"]: + """ + Returns the enum member matching the given description. + + Args: + description (str): The description to search for. + + Returns: + Optional[ClinicalCeaseReasonType]: The matching enum member, or None if not found. + """ + if not hasattr(cls, "_descriptions"): + cls._build_lookup_maps() + return cls._descriptions.get(description) + + @classmethod + def by_description_case_insensitive( + cls, description: str + ) -> Optional["ClinicalCeaseReasonType"]: + """ + Returns the enum member matching the given description (case-insensitive). + + Args: + description (str): The description to search for. + + Returns: + Optional[ClinicalCeaseReasonType]: The matching enum member, or None if not found. + """ + if not hasattr(cls, "_lowercase_descriptions"): + cls._build_lookup_maps() + return cls._lowercase_descriptions.get(description.lower()) + + @classmethod + def by_valid_value_id( + cls, valid_value_id: int + ) -> Optional["ClinicalCeaseReasonType"]: + """ + Returns the enum member matching the given valid value ID. + + Args: + valid_value_id (int): The valid value ID to search for. + + Returns: + Optional[ClinicalCeaseReasonType]: The matching enum member, or None if not found. + """ + if not hasattr(cls, "_valid_value_ids"): + cls._build_lookup_maps() + return cls._valid_value_ids.get(valid_value_id) diff --git a/classes/date_description.py b/classes/date_description.py new file mode 100644 index 00000000..b37063ac --- /dev/null +++ b/classes/date_description.py @@ -0,0 +1,214 @@ +from enum import Enum +from typing import Optional +from datetime import date, timedelta +import random + + +class DateDescription(Enum): + """ + Enum representing various date descriptions and their associated logic. + + Each member contains: + - description (str): A human-readable description of the date logic. + - number_of_months (int): The number of months relevant to the date logic. + - suitable_date (Optional[date]): A calculated or representative date, if applicable. + + Example members: + AFTER_TODAY: A date after today, randomly chosen up to 1000 days in the future. + BEFORE_TODAY: A date before today, randomly chosen up to 1000 days in the past. + TODAY: Today's date. + YESTERDAY: Yesterday's date. + NULL: Represents a null value. + NOT_NULL: Represents a non-null value. + """ + + AFTER_TODAY = ( + "after today", + 0, + date.today() + timedelta(days=random.randint(1, 1000)), + ) + AS_AT_EPISODE_START = ("as at episode start", 0, None) + BEFORE_TODAY = ( + "before today", + 0, + date.today() - timedelta(days=random.randint(1, 1000)), + ) + CALCULATED_FOBT_DUE_DATE = ("calculated fobt due date", 0, None) + CALCULATED_LYNCH_DUE_DATE = ("calculated lynch due date", 0, None) + CALCULATED_SCREENING_DUE_DATE = ("calculated screening due date", 0, None) + CALCULATED_SURVEILLANCE_DUE_DATE = ("calculated surveillance due date", 0, None) + CSDD = ("csdd", 0, None) + CSSDD = ("cssdd", 0, None) + GREATER_THAN_TODAY = ( + "> today", + 0, + date.today() + timedelta(days=random.randint(1, 1000)), + ) + LAST_BIRTHDAY = ("last birthday", 0, None) + LESS_THAN_2_YEARS_AGO = ( + "less than 2 years ago", + 24, + date.today() - timedelta(days=random.randint(1, 730)), + ) + LESS_THAN_TODAY = ( + "< today", + 0, + date.today() - timedelta(days=random.randint(1, 1000)), + ) + LESS_THAN_OR_EQUAL_TO_6_MONTHS_AGO = ( + "<= 6 months ago", + 6, + date.today() - timedelta(days=random.randint(0, 182)), + ) + LYNCH_DIAGNOSIS_DATE = ("lynch diagnosis date", 0, None) + MORE_THAN_2_YEARS_AGO = ( + "more than 2 years ago", + 24, + date.today() - timedelta(days=730 + random.randint(1, 1000)), + ) + MORE_THAN_3_YEARS_AGO = ( + "more than 3 years ago", + 36, + date.today() - timedelta(days=1095 + random.randint(1, 1000)), + ) + MORE_THAN_6_MONTHS_AGO = ( + "more than 6 months ago", + 6, + date.today() - timedelta(days=182 + random.randint(1, 1000)), + ) + MORE_THAN_10_DAYS_AGO = ( + "> 10 days ago", + 0, + date.today() - timedelta(days=random.randint(11, 1010)), + ) + MORE_THAN_20_DAYS_AGO = ( + "> 20 days ago", + 0, + date.today() - timedelta(days=random.randint(21, 1020)), + ) + NOT_NULL = ("not null", 0, None) + NULL = ("null", 0, None) + ONE_YEAR_FROM_DIAGNOSTIC_TEST = ("1 year from diagnostic test", 12, None) + ONE_YEAR_FROM_EPISODE_END = ("1 year from episode end", 12, None) + ONE_YEAR_FROM_SYMPTOMATIC_PROCEDURE = ( + "1 year from symptomatic procedure", + 12, + None, + ) + THREE_YEARS_FROM_DIAGNOSTIC_TEST = ("3 years from diagnostic test", 36, None) + THREE_YEARS_FROM_EPISODE_END = ("3 years from episode end", 36, None) + THREE_YEARS_FROM_SYMPTOMATIC_PROCEDURE = ( + "3 years from symptomatic procedure", + 36, + None, + ) + TODAY = ("today", 0, date.today()) + TOMORROW = ("tomorrow", 0, date.today() + timedelta(days=1)) + TWO_YEARS_FROM_DIAGNOSTIC_TEST = ("2 years from diagnostic test", 24, None) + TWO_YEARS_FROM_EPISODE_END = ("2 years from episode end", 24, None) + TWO_YEARS_FROM_LAST_LYNCH_COLONOSCOPY_DATE = ( + "2 years from last lynch colonoscopy date", + 24, + None, + ) + TWO_YEARS_FROM_LATEST_A37_EVENT = ("2 years from latest A37 event", 24, None) + TWO_YEARS_FROM_LATEST_J8_EVENT = ("2 years from latest J8 event", 24, None) + TWO_YEARS_FROM_LATEST_J15_EVENT = ("2 years from latest J15 event", 24, None) + TWO_YEARS_FROM_LATEST_J16_EVENT = ("2 years from latest J16 event", 24, None) + TWO_YEARS_FROM_LATEST_J25_EVENT = ("2 years from latest J25 event", 24, None) + TWO_YEARS_FROM_EARLIEST_S10_EVENT = ("2 years from earliest S10 event", 24, None) + TWO_YEARS_FROM_LATEST_S158_EVENT = ("2 years from latest S158 event", 24, None) + TWO_YEARS_FROM_SYMPTOMATIC_PROCEDURE = ( + "2 years from symptomatic procedure", + 24, + None, + ) + UNCHANGED = ("unchanged", 0, None) + UNCHANGED_NULL = ("unchanged (null)", 0, None) + WITHIN_THE_LAST_2_YEARS = ( + "within the last 2 years", + 24, + date.today() - timedelta(days=random.randint(0, 730)), + ) + WITHIN_THE_LAST_4_YEARS = ( + "within the last 4 years", + 48, + date.today() - timedelta(days=random.randint(0, 1460)), + ) + WITHIN_THE_LAST_6_MONTHS = ( + "within the last 6 months", + 6, + date.today() - timedelta(days=random.randint(0, 182)), + ) + YESTERDAY = ("yesterday", 0, date.today() - timedelta(days=1)) + + def __init__( + self, description: str, number_of_months: int, suitable_date: Optional[date] + ): + """ + Initialize a DateDescription enum member. + + Args: + description (str): The human-readable description of the date logic. + number_of_months (int): The number of months relevant to the date logic. + suitable_date (Optional[date]): A calculated or representative date, if applicable. + """ + self._description = description + self._number_of_months = number_of_months + self._suitable_date = suitable_date + + @property + def description(self) -> str: + """ + Returns the human-readable description of the date logic. + + Returns: + str: The description. + """ + return self._description + + @property + def number_of_months(self) -> int: + """ + Returns the number of months relevant to the date logic. + + Returns: + int: The number of months. + """ + return self._number_of_months + + @property + def suitable_date(self) -> Optional[date]: + """ + Returns a calculated or representative date, if applicable. + + Returns: + Optional[date]: The suitable date, or None if not applicable. + """ + return self._suitable_date + + @classmethod + def by_description(cls, desc: str) -> Optional["DateDescription"]: + """ + Returns the enum member matching the given description. + + Args: + desc (str): The description to search for. + + Returns: + Optional[DateDescription]: The matching enum member, or None if not found. + """ + return next((d for d in cls if d.description == desc), None) + + @classmethod + def by_description_case_insensitive(cls, desc: str) -> Optional["DateDescription"]: + """ + Returns the enum member matching the given description (case-insensitive). + + Args: + desc (str): The description to search for. + + Returns: + Optional[DateDescription]: The matching enum member, or None if not found. + """ + return next((d for d in cls if d.description.lower() == desc.lower()), None) diff --git a/classes/diagnostic_test_has_outcome_of_result.py b/classes/diagnostic_test_has_outcome_of_result.py new file mode 100644 index 00000000..6837ecc0 --- /dev/null +++ b/classes/diagnostic_test_has_outcome_of_result.py @@ -0,0 +1,70 @@ +class DiagnosticTestHasOutcomeOfResult: + """ + Utility class for mapping diagnostic test outcome-of-result descriptions to logical flags or valid value IDs. + + This class provides: + - Logical flags for "yes" and "no" outcomes. + - A mapping from descriptive outcome labels (e.g., "referred", "treated") to internal valid value IDs. + - Methods to convert descriptions to flags or IDs. + + Methods: + from_description(description: str) -> str | int: + Returns the logical flag ("yes"/"no") or the valid value ID for a given description. + Raises ValueError if the description is not recognized. + + get_id(description: str) -> int: + Returns the valid value ID for a given outcome description. + Raises ValueError if the description is not recognized or has no ID. + """ + + YES = "yes" + NO = "no" + + _label_to_id = { + "referred": 9101, + "treated": 9102, + "not required": 9103, + # Extend as needed + } + + _valid_flags = {YES, NO} + + @classmethod + def from_description(cls, description: str): + """ + Returns the logical flag ("yes"/"no") or the valid value ID for a given description. + + Args: + description (str): The outcome-of-result description. + + Returns: + str | int: The logical flag ("yes"/"no") or the valid value ID. + + Raises: + ValueError: If the description is not recognized. + """ + key = description.strip().lower() + if key in cls._valid_flags: + return key + if key in cls._label_to_id: + return cls._label_to_id[key] + raise ValueError(f"Unknown outcome-of-result description: '{description}'") + + @classmethod + def get_id(cls, description: str) -> int: + """ + Returns the valid value ID for a given outcome description. + + Args: + description (str): The outcome-of-result description. + + Returns: + int: The valid value ID. + + Raises: + ValueError: If the description is not recognized or has no ID. + """ + key = description.strip().lower() + if key not in cls._label_to_id: + raise ValueError(f"No ID available for outcome: '{description}'") + return cls._label_to_id[key] diff --git a/classes/diagnostic_test_has_result.py b/classes/diagnostic_test_has_result.py new file mode 100644 index 00000000..48877524 --- /dev/null +++ b/classes/diagnostic_test_has_result.py @@ -0,0 +1,70 @@ +class DiagnosticTestHasResult: + """ + Utility class for mapping diagnostic test result descriptions to logical flags or internal IDs. + + This class provides: + - Logical flags for "yes" and "no" results. + - A mapping from descriptive result labels (e.g., "positive", "negative", "indeterminate") to internal valid value IDs. + - Methods to convert descriptions to flags or IDs. + + Methods: + from_description(description: str) -> str | int: + Returns the logical flag ("yes"/"no") or the valid value ID for a given description. + Raises ValueError if the description is not recognized. + + get_id(description: str) -> int: + Returns the valid value ID for a given result description. + Raises ValueError if the description is not recognized or has no ID. + """ + + YES = "yes" + NO = "no" + + _label_to_id = { + "positive": 9001, + "negative": 9002, + "indeterminate": 9003, + # Add additional mappings as needed + } + + _valid_flags = {YES, NO} + + @classmethod + def from_description(cls, description: str): + """ + Returns the logical flag ("yes"/"no") or the valid value ID for a given description. + + Args: + description (str): The diagnostic test result description. + + Returns: + str | int: The logical flag ("yes"/"no") or the valid value ID. + + Raises: + ValueError: If the description is not recognized. + """ + key = description.strip().lower() + if key in cls._valid_flags: + return key + if key in cls._label_to_id: + return cls._label_to_id[key] + raise ValueError(f"Unknown diagnostic test result description: '{description}'") + + @classmethod + def get_id(cls, description: str) -> int: + """ + Returns the valid value ID for a given result description. + + Args: + description (str): The diagnostic test result description. + + Returns: + int: The valid value ID. + + Raises: + ValueError: If the description is not recognized or has no ID. + """ + key = description.strip().lower() + if key not in cls._label_to_id: + raise ValueError(f"No ID available for result description: '{description}'") + return cls._label_to_id[key] diff --git a/classes/diagnostic_test_is_void.py b/classes/diagnostic_test_is_void.py new file mode 100644 index 00000000..3a2ba16c --- /dev/null +++ b/classes/diagnostic_test_is_void.py @@ -0,0 +1,37 @@ +class DiagnosticTestIsVoid: + """ + Utility class for mapping descriptive yes/no flags to test void state checks. + + This class provides: + - Logical flags for "yes" and "no" to indicate if a diagnostic test is void. + - A method to convert a description to a valid flag. + + Methods: + from_description(description: str) -> str: + Returns the logical flag ("yes" or "no") for a given description. + Raises ValueError if the description is not recognized. + """ + + YES = "yes" + NO = "no" + + _valid_values = {YES, NO} + + @classmethod + def from_description(cls, description: str) -> str: + """ + Returns the logical flag ("yes" or "no") for a given description. + + Args: + description (str): The description to check (e.g., "yes" or "no"). + + Returns: + str: The logical flag ("yes" or "no"). + + Raises: + ValueError: If the description is not recognized. + """ + key = description.strip().lower() + if key not in cls._valid_values: + raise ValueError(f"Unknown test void flag: '{description}'") + return key diff --git a/classes/diagnostic_test_type.py b/classes/diagnostic_test_type.py new file mode 100644 index 00000000..3ec9302f --- /dev/null +++ b/classes/diagnostic_test_type.py @@ -0,0 +1,39 @@ +class DiagnosticTestType: + """ + Utility class for mapping descriptive diagnostic test types to valid value IDs. + + This class provides: + - A mapping between human-readable diagnostic test type descriptions (e.g., "pcr", "antigen", "lateral flow") and their corresponding internal valid value IDs. + - A method to retrieve the valid value ID for a given description. + + Methods: + get_valid_value_id(description: str) -> int: + Returns the valid value ID for a given diagnostic test type description. + Raises ValueError if the description is not recognized. + """ + + _mapping = { + "pcr": 3001, + "antigen": 3002, + "lateral flow": 3003, + # Add more mappings as needed + } + + @classmethod + def get_valid_value_id(cls, description: str) -> int: + """ + Returns the valid value ID for a given diagnostic test type description. + + Args: + description (str): The diagnostic test type description (e.g., "pcr"). + + Returns: + int: The valid value ID corresponding to the description. + + Raises: + ValueError: If the description is not recognized. + """ + key = description.strip().lower() + if key not in cls._mapping: + raise ValueError(f"Unknown diagnostic test type: '{description}'") + return cls._mapping[key] diff --git a/classes/does_subject_have_surveillance_review_case.py b/classes/does_subject_have_surveillance_review_case.py new file mode 100644 index 00000000..7e8f0c1c --- /dev/null +++ b/classes/does_subject_have_surveillance_review_case.py @@ -0,0 +1,39 @@ +class DoesSubjectHaveSurveillanceReviewCase: + """ + Utility class for mapping binary criteria for the presence of a surveillance review case. + + This class provides: + - Logical flags for "yes" and "no" to indicate if a subject has a surveillance review case. + - A method to convert a description to a valid flag. + + Methods: + from_description(description: str) -> str: + Returns the logical flag ("yes" or "no") for a given description. + Raises ValueError if the description is not recognized. + """ + + YES = "yes" + NO = "no" + + _valid_values = {YES, NO} + + @classmethod + def from_description(cls, description: str) -> str: + """ + Returns the logical flag ("yes" or "no") for a given description. + + Args: + description (str): The description to check (e.g., "yes" or "no"). + + Returns: + str: The logical flag ("yes" or "no"). + + Raises: + ValueError: If the description is not recognized. + """ + key = description.strip().lower() + if key not in cls._valid_values: + raise ValueError( + f"Unknown surveillance review case presence: '{description}'" + ) + return key diff --git a/classes/episode_result_type.py b/classes/episode_result_type.py new file mode 100644 index 00000000..9b0b39b6 --- /dev/null +++ b/classes/episode_result_type.py @@ -0,0 +1,46 @@ +class EpisodeResultType: + """ + Utility class for mapping episode result type descriptions to logical flags or valid value IDs. + + This class provides: + - Logical flags for "null", "not_null", and "any_surveillance_non_participation". + - A mapping from descriptive result labels (e.g., "normal", "abnormal", "surveillance offered") to internal valid value IDs. + - A method to convert descriptions to flags or IDs. + + Methods: + from_description(description: str) -> str | int: + Returns the logical flag or the valid value ID for a given description. + Raises ValueError if the description is not recognized. + """ + + NULL = "null" + NOT_NULL = "not_null" + ANY_SURVEILLANCE_NON_PARTICIPATION = "any_surveillance_non_participation" + + _label_to_id = { + "normal": 9501, + "abnormal": 9502, + "surveillance offered": 9503, + # Add real mappings as needed + } + + @classmethod + def from_description(cls, description: str): + """ + Returns the logical flag or the valid value ID for a given description. + + Args: + description (str): The episode result type description. + + Returns: + str | int: The logical flag or the valid value ID. + + Raises: + ValueError: If the description is not recognized. + """ + key = description.strip().lower() + if key in {cls.NULL, cls.NOT_NULL, cls.ANY_SURVEILLANCE_NON_PARTICIPATION}: + return key + if key in cls._label_to_id: + return cls._label_to_id[key] + raise ValueError(f"Unknown episode result type: '{description}'") diff --git a/classes/episode_type.py b/classes/episode_type.py new file mode 100644 index 00000000..8bc9df40 --- /dev/null +++ b/classes/episode_type.py @@ -0,0 +1,104 @@ +from enum import Enum +from typing import Optional + + +class EpisodeType(Enum): + """ + Enum representing different types of screening episodes. + + Members: + FOBT: Faecal Occult Blood Test (short description) + Fobt: Faecal Occult Blood Test (long description) + BowelScope: Bowel Scope episode + Surveillance: Surveillance episode + LYNCH_SURVEILLANCE: Lynch Surveillance episode (long description) + Lynch: Lynch Surveillance episode (short description) + """ + + FOBT = (11350, "FOBT") + Fobt = (11350, "FOBT Screening") + BowelScope = (200640, "Bowel Scope") + Surveillance = (11351, "Surveillance") + LYNCH_SURVEILLANCE = (305633, "Lynch Surveillance") + Lynch = (305633, "Lynch") + + def __init__(self, valid_value_id, description): + """ + Initialize an EpisodeType enum member. + + Args: + valid_value_id (int): The unique identifier for the episode type. + description (str): The string description of the episode type. + """ + self._valid_value_id = valid_value_id + self._description = description + + @property + def valid_value_id(self) -> int: + """ + Returns the unique identifier for the episode type. + + Returns: + int: The valid value ID. + """ + return self._valid_value_id + + @property + def description(self) -> str: + """ + Returns the string description of the episode type. + + Returns: + str: The description. + """ + return self._description + + @classmethod + def by_description(cls, description: str) -> Optional["EpisodeType"]: + """ + Returns the enum member matching the given description. + + Args: + description (str): The description to search for. + + Returns: + Optional[EpisodeType]: The matching enum member, or None if not found. + """ + for member in cls: + if member.description == description: + return member + return None + + @classmethod + def by_description_case_insensitive( + cls, description: str + ) -> Optional["EpisodeType"]: + """ + Returns the enum member matching the given description (case-insensitive). + + Args: + description (str): The description to search for. + + Returns: + Optional[EpisodeType]: The matching enum member, or None if not found. + """ + for member in cls: + if member.description.lower() == description.lower(): + return member + return None + + @classmethod + def by_valid_value_id(cls, valid_value_id: int) -> Optional["EpisodeType"]: + """ + Returns the enum member matching the given valid value ID. + + Args: + valid_value_id (int): The valid value ID to search for. + + Returns: + Optional[EpisodeType]: The matching enum member, or None if not found. + """ + for member in cls: + if member.valid_value_id == valid_value_id: + return member + return None diff --git a/classes/event_status_type.py b/classes/event_status_type.py new file mode 100644 index 00000000..14a37323 --- /dev/null +++ b/classes/event_status_type.py @@ -0,0 +1,1105 @@ +from enum import Enum +from typing import Optional + + +class EventStatusType(Enum): + """ + Enum representing various event status types for screening and diagnostic events. + + Each member contains: + - id (int): The unique identifier for the event status. + - code (str): The event status code. + - description (str): A human-readable description of the event status. + + Example members: + A100: Suitable for Radiological Test + A101: Pending decision to proceed with Endoscopic Test + ... + U98: Weak Positive, Waiting for Programme Hub Assistance + + Methods: + id: Returns the unique identifier for the event status. + code: Returns the event status code. + description: Returns the human-readable description of the event status. + get_by_id(id_: int) -> Optional[EventStatusType]: Returns the enum member matching the given id. + get_by_code(code: str) -> Optional[EventStatusType]: Returns the enum member matching the given code. + get_by_description(description: str) -> Optional[EventStatusType]: Returns the enum member matching the given description. + """ + + A100 = (11101, "A100", "Suitable for Radiological Test") + A101 = (11102, "A101", "Pending decision to proceed with Endoscopic Test") + A103 = (11103, "A103", "No Decision Received to Proceed with Diagnostic Tests") + A156 = (160185, "A156", "Intermediate-risk Adenoma") + A157 = (305616, "A157", "LNPCP") + A158 = (305617, "A158", "High-risk findings") + A165 = (11105, "A165", "Waiting Decision to Proceed with Diagnostic Test") + A166 = ( + 11106, + "A166", + "GP Discharge Sent (No show for Colonoscopy Assessment Appointment)", + ) + A167 = (11107, "A167", "GP Abnormal FOBT Result Sent") + A168 = ( + 11108, + "A168", + "GP Discharge Sent (No Agreement to Proceed with Diagnostic Tests)", + ) + A172 = (160152, "A172", "DNA Diagnostic Test") + A183 = (11111, "A183", "1st Colonoscopy Assessment Appointment Requested") + A184 = (11112, "A184", "2nd Colonoscopy Assessment Appointment Requested") + A185 = ( + 11113, + "A185", + "2nd Colonoscopy Assessment Appointment Non-attendance (Patient)", + ) + A196 = (11114, "A196", "Follow Up Questionnaire Sent (Abnormal Finding)") + A197 = (11115, "A197", "Removed from phase 2A") + A198 = (11116, "A198", "Follow Up Questionnaire Sent (High-risk Adenoma)") + A199 = (11117, "A199", "Follow Up Questionnaire Sent (Intermediate-risk Adenoma)") + A200 = (11118, "A200", "Follow Up Questionnaire Sent (Low-risk Adenoma)") + A227 = (160193, "A227", "Confirmed Diagnostic Test") + A227A259 = ( + 160225, + "A227/A259", + "Redirect back to A227 or A259 (depending on the previous Diagnostic Test)", + ) + A25 = (11119, "A25", "1st Colonoscopy Assessment Appointment Booked, letter sent") + A259 = (160194, "A259", "Attended Diagnostic Test") + A26 = (11120, "A26", "2nd Colonoscopy Assessment Appointment Booked, letter sent") + A27 = (11121, "A27", "Invited for Colonoscopy") + A27A59 = ( + 160190, + "A27/A59", + "Redirect back to A27 or A59 (depending on the previous Diagnostic Test)", + ) + A300 = (160154, "A300", "Failed Test - Refer Another") + A305 = (160203, "A305", "Consent Refused, Refer Another") + A306 = (160169, "A306", "Cancel Diagnostic Test") + A307 = (160222, "A307", "Consent Refused (prior to investigation)") + A309 = (204298, "A309", "Normal, Return to FOBT, GP Communication Created") + A310 = (160205, "A310", "Withdrawn Consent, Refer Another") + A315 = (160206, "A315", "Diagnostic Test Outcome Entered") + A316 = (160218, "A316", "Post-investigation Appointment Attended") + A317 = (160219, "A317", "Post-investigation Contact Made") + A318 = ( + 160221, + "A318", + "Post-investigation Appointment NOT Required - Result Letter Created", + ) + A320 = (160160, "A320", "Refer Another Test") + A321 = (203141, "A321", "Manual Patient Result Letter Created") + A322 = (203142, "A322", "GP Copy of Manual Patient Result Letter on Queue") + A323 = (160182, "A323", "Post-investigation Appointment NOT Required") + A324 = ( + 204304, + "A324", + "Post-investigation Appt Attended, Normal Result, GP Communication Created", + ) + A325 = ( + 204295, + "A325", + "Attended Not Screened, Return to FOBT, Patient Result Letter Created", + ) + A326 = ( + 204296, + "A326", + "Attended Not Screened, Return to FOBT, GP Communication Created", + ) + A327 = (204297, "A327", "Normal, Return to FOBT, Patient Result Letter Created") + A328 = (204313, "A328", "Handover into Symptomatic Care (Bowel scope)") + A329 = ( + 204299, + "A329", + "Post-investigation Appt Attended, Abnormal Patient Result Letter Created", + ) + A330 = ( + 204301, + "A330", + "Post-investigation Appt Attended, Attended Not Screened Patient Result Letter Created", + ) + A331 = ( + 204303, + "A331", + "Post-investigation Appt Attended, Normal Patient Result Letter Created", + ) + A335 = ( + 204300, + "A335", + "Post-investigation Appt Attended, Abnormal Result, GP Communication Created", + ) + A336 = ( + 204302, + "A336", + "Post-investigation Appt Attended, Attended Not Screened Result, GP Communication Created", + ) + A338 = (204305, "A338", "Abnormal, Return to FOBT, Patient Result Letter Created") + A339 = (204306, "A339", "Abnormal, Return to FOBT, GP Communication Created") + A340 = (160155, "A340", "Normal (No Abnormalities Found)") + A341 = (160156, "A341", "Low-risk Adenoma") + A342 = (160157, "A342", "Intermediate-risk Adenoma") + A343 = (160158, "A343", "High-risk Adenoma") + A344 = (160159, "A344", "Abnormal") + A345 = (160153, "A345", "Cancer Result, Refer MDT") + A346 = (160163, "A346", "Handover into Symptomatic Care") + A347 = (20074, "A347", "Refer to Symptomatic") + A348 = (20075, "A348", "MDT Referral Required") + A350 = ( + 160166, + "A350", + "Letter of Non-agreement to Continue with Investigation sent to GP", + ) + A351 = (160168, "A351", "GP Discharge Letter Printed - No Patient Contact") + A352 = (160162, "A352", "Non-attendance of Diagnostic Test - DNA Letter Printed") + A353 = (20229, "A353", "MDT Referral Not Required") + A354 = (20123, "A354", "Contact Outcome = SSP Appointment Required") + A355 = (20124, "A355", "Contact Outcome = Further Contact Required") + A356 = ( + 20428, + "A356", + "Handover into Symptomatic Care, Patient Unfit, GP Letter Printed", + ) + A357 = (20429, "A357", "Patient Unfit, Handover into Symptomatic Care") + A358 = (202000, "A358", "Return to FOBT After Symptomatic Referral") + A360 = (160171, "A360", "Post-investigation Appointment Required") + A361 = (160172, "A361", "Other Post-investigation Contact Required") + A362 = (20078, "A362", "Refer to surgery - Post-investigation attended") + A363 = (20079, "A363", "Cancer Detected - Post-investigation Attended") + A364 = (20227, "A364", "Cancer Detected - Post-investigation Not Required") + A365 = (20228, "A365", "Refer Surgery - Post-investigation Not Required") + A37 = ( + 11123, + "A37", + "Patient Discharge Sent (Non-attendance at Colonoscopy Assessment Appointment)", + ) + A370 = (160161, "A370", "Diagnostic Test Result Letter sent to GP") + A371 = (20080, "A371", "Surgery Patient Result letter Printed") + A372 = (20081, "A372", "Refer Symptomatic, GP Letter Printed") + A374 = (20082, "A374", "Return to Surveillance After Symptomatic Referral") + A375 = ( + 160176, + "A375", + "Diagnostic Test Result Patient Letter Printed - Post-investigation Clinic List", + ) + A376 = (20430, "A376", "Polyps not cancer, return to screening") + A377 = (305701, "A377", "Return to Lynch after symptomatic referral") + A38 = (11124, "A38", "Decision not to Continue with Diagnostic Test") + A380 = (160164, "A380", "Failed Diagnostic Test - Refer Another") + A382 = (11553, "A382", "Handover into Symptomatic Care - GP Letter Printed") + A383 = (20421, "A383", "Handover into Symptomatic Care - Patient Letter Printed") + A384 = (20420, "A384", "Discharged from Screening - GP letter not required") + A385 = (20419, "A385", "Handover into Symptomatic Care") + A391 = (20083, "A391", "Patient Discharge Letter Printed - No Patient Contact") + A392 = (20084, "A392", "Patient Discharge Letter Printed - Patient Choice") + A394 = ( + 20418, + "A394", + "Handover into Symptomatic Care for Surveillance - Patient Age", + ) + A395 = (160188, "A395", "Refer Another Diagnostic Test") + A396 = (160165, "A396", "Discharged from Screening Round - Patient Choice") + A397 = (160167, "A397", "Discharged from Screening Round - No Patient Contact") + A400 = (160207, "A400", "Follow-up Test Cancelled by Screening Centre") + A401 = (160208, "A401", "Patient Declined Follow-up Test") + A402 = (160209, "A402", "Diagnostic Test Outcome Entered") + A403 = (160229, "A403", "Reschedule Follow-up Test as Surveillance") + A404 = (11104, "A404", "Changed Diagnostic Test Data") + A410 = (160173, "A410", "Post-investigation Appointment Made") + A415 = (160175, "A415", "Post-investigation Appointment Invitation Letter Printed") + A416 = (160210, "A416", "Post-investigation Appointment Attended") + A417 = (160177, "A417", "Post-investigation Appointment Cancelled by SC") + A420 = (160178, "A420", "Post-investigation Appointment Cancelled by Patient") + A422 = ( + 160181, + "A422", + "Post-investigation Appointment Cancellation Letter Printed", + ) + A425 = ( + 160179, + "A425", + "Practitioner did not attend Post-investigation Appointment", + ) + A430 = ( + 160189, + "A430", + "Post-investigation Appointment Attended - Diagnostic Result Letter not Printed", + ) + A441 = (160180, "A441", "Patient did not attend Post-investigation Appointment") + A45 = (11125, "A45", "Weak Positive (Weak Positive)") + A50 = (305489, "A50", "Diagnosis date recorded") + A51 = (305498, "A51", "Diagnosis date amended") + A52 = (305499, "A52", "No diagnosis date recorded") + A59 = (11126, "A59", "Invited for Diagnostic Test") + A60 = (11127, "A60", "Decision not to Continue with Other Tests") + A61 = (160211, "A61", "Consent Withdrawn Diagnostic Test - Result Letter Printed") + A62 = (160184, "A62", "Low-risk Adenoma") + A63 = (160170, "A63", "Cancer") + A64 = (160186, "A64", "High-risk Adenoma") + A65 = (160187, "A65", "Abnormal/No Result") + A8 = (11132, "A8", "Abnormal") + A85 = (11133, "A85", "Waiting for Clinician Review") + A99 = (11136, "A99", "Suitable for Endoscopic Test") + ADHOCLETT = (11137, "ADHOCLETT", "Individual Letter") + ANY = (11138, "ANY*", "Any Event Code") + ANY_IF_NOT_CLOSED = ( + 11295, + "ANY_IF_NOT_CLOSED*", + "Any Event Status if Episode is not Closed", + ) + ANY_IF_OPEN = (11294, "ANY_IF_OPEN*", "Any Event Status if Episode is Open") + ANY_OR_NO_EPI = (15005, "ANY_OR_NO_EPI*", "Any Event Code") + C1 = (35, "C1", "Manual Cease Requested (Disclaimer Letter Required)") + C10 = (40, "C10", "Manual Cease Request Removed") + C11 = (41, "C11", "Manual Cease Requested (Immediate Cease)") + C2 = (36, "C2", "Disclaimer Letter Marked as Sent") + C203 = (11288, "C203", "Episode Closed") + C4 = ( + 37, + "C4", + "Receipt of Returned Disclaimer Letter Recorded - Manual Cease Confirmed", + ) + C5 = ( + 38, + "C5", + "Receipt of Returned Disclaimer Letter Recorded - Manual Do Not Cease Confirmed", + ) + C9 = (39, "C9", "Uncease After Manual Cease") + ContinueFromPending = (11287, "ContinueFromPending", "Continue from Pending") + DUMMY = ( + 11296, + "DUMMY", + "Pseudo Event Status created to allow Event Transition Processing to work", + ) + E73 = (209020, "E73", "Reopen to book a bowel scope appointment for a correction") + E74 = ( + 205009, + "E74", + "Reopen to book a bowel scope appointment following subject decision", + ) + E75 = ( + 209017, + "E75", + "Reopen bowel scope Screening episode after Returned/Undelivered Mail", + ) + E76 = (209018, "E76", "Print Returned/Undelivered mail letter") + E79 = ( + 209019, + "E79", + "Automatic Reopen bowel scope Screening episode after Returned/Undelivered Mail", + ) + F1 = (200650, "F1", "Selected for bowel scope Screening") + F10 = (200652, "F10", "Bowel scope Screening Invitation and appointment sent") + F100 = ( + 208068, + "F100", + "Unable to contact subject to complete bowel scope Suitability Assessment (GP) letter sent", + ) + F11 = (200654, "F11", "Selected for bowel scope Screening Reminder") + F12 = (200655, "F12", "Bowel scope Screening Reminder sent") + F13 = ( + 200656, + "F13", + "Bowel scope Appointment Invitation Sent (Subject not responded yet)", + ) + F14 = (200657, "F14", "Bowel scope Screening Non-Response Sent") + F15 = (209009, "F15", "Selected for bowel scope Screening Non-Response") + F16 = (209010, "F16", "Bowel scope Screening Non-Response sent") + F172 = (206040, "F172", "DNA bowel scope") + F173 = (206041, "F173", "Subject Discharge Letter Printed - DNA bowel scope") + F174 = (206042, "F174", "GP Discharge Letter Printed - DNA bowel scope") + F175 = (203157, "F175", "Subject DNA bowel scope") + F18 = ( + 206010, + "F18", + "Bowel scope Appointment Cancelled by Screening Centre (prior to issue)", + ) + F19 = ( + 206011, + "F19", + "Bowel scope List Cancelled by Screening Centre (prior to issue)", + ) + F199 = (203024, "F199", "Refer Colonoscopy") + F2 = (206000, "F2", "Initial bowel scope appointment linked to episode") + F20 = (206043, "F20", "Responded to bowel scope Screening Invitation") + F200 = (203022, "F200", "Colonoscopy Assessment Appointment Required") + F201 = (205044, "F201", "Colonoscopy Assessment Appointment Requested") + F202 = ( + 205045, + "F202", + "Colonoscopy Assessment Appointment Requested following DNA", + ) + F203 = (205046, "F203", "2nd Colonoscopy Assessment Appointment Non Attendance") + F204 = ( + 205047, + "F204", + "Colonoscopy Assessment Appointment Non-attendance (Patient)", + ) + F205 = ( + 205048, + "F205", + "Colonoscopy Assessment Appointment Requested (Screening Centre Cancellation Letter)", + ) + F206 = ( + 205049, + "F206", + "Colonoscopy Assessment Appointment Cancellation (Screening Centre)", + ) + F207 = ( + 205050, + "F207", + "Colonoscopy Assessment Appointment Requested (Patient to Reschedule Letter)", + ) + F208 = ( + 205051, + "F208", + "Colonoscopy Assessment Appointment Cancellation Sent (Patient to Consider)", + ) + F209 = ( + 205052, + "F209", + "Colonoscopy Assessment Appointment Non-attendance Letter Sent (Patient)", + ) + F21 = ( + 206012, + "F21", + "Bowel scope Appointment Cancelled by Screening Centre (Confirmed)", + ) + F210 = ( + 205053, + "F210", + "Colonoscopy Assessment Appointment Non-attendance (Screening Centre)", + ) + F212 = ( + 205054, + "F212", + "Colonoscopy Assessment Appointment Requested (Screening Centre Non-attendance Letter)", + ) + F213 = ( + 205055, + "F213", + "Colonoscopy Assessment Appointment Cancellation (Patient to Consider Letter)", + ) + F214 = (205056, "F214", "Colonoscopy Assessment Appointment Cancellation (Patient)") + F215 = ( + 205057, + "F215", + "Patient Discharge Sent (Non-attendance at Colonoscopy Assessment Appointment)", + ) + F216 = (205058, "F216", "Colonoscopy Assessment Appointment Booked, letter sent") + F217 = ( + 205059, + "F217", + "Colonoscopy Assessment Appointment Booked following DNA, letter sent", + ) + F218 = ( + 205060, + "F218", + "GP Discharge Sent (Non-attendance at Colonoscopy Assessment Appointment)", + ) + F219 = ( + 205061, + "F219", + "Colonoscopy Assessment Appointment Cancellation Requested by Screening Centre prior to Preparation of Letter", + ) + F22 = ( + 206013, + "F22", + "Bowel scope Appointment Cancelled by Screening Centre (Not Responded Yet)", + ) + F220 = (205062, "F220", "Colonoscopy Assessment Appointment Request (Redirected)") + F221 = (205063, "F221", "Colonoscopy Assessment Appointment Requested (Redirected)") + F222 = (204311, "F222", "GP Abnormal Result Created (Referred for Colonoscopy)") + F223 = (205064, "F223", "Colonoscopy Assessment Appointment Rescheduled") + F224 = ( + 205065, + "F224", + "Patient Discharge Sent (Refused Colonoscopy Assessment Appointment)", + ) + F225 = ( + 205066, + "F225", + "GP Discharge Letter Sent (Refused Colonoscopy Assessment Appointment)", + ) + F227 = ( + 205067, + "F227", + "Post-investigation not Colonoscopy Assessment Appointment Required - letter to patient", + ) + F228 = (204312, "F228", "GP Abnormal Result Created") + F229 = (204258, "F229", "GP Abnormal Result Sent") + F23 = (206014, "F23", "Bowel scope Appointment Re-allocated by Screening Centre") + F24 = (206015, "F24", "Bowel scope Appointment Cancelled by Subject (Confirmed)") + F25 = (206016, "F25", "Bowel scope Appointment Cancelled by Subject (Responded)") + F26 = ( + 206017, + "F26", + "Bowel scope Appointment Cancelled by Screening Centre (Responded)", + ) + F27 = (206018, "F27", "Bowel scope List Cancelled by Screening Centre (Confirmed)") + F28 = (206019, "F28", "Bowel scope List Cancelled by Screening Centre (Responded)") + F29 = ( + 206020, + "F29", + "Bowel scope List Cancelled by Screening Centre (Not Responded Yet)", + ) + F30 = (206021, "F30", "Bowel scope Appointment Booked") + F31 = (206022, "F31", "Bowel scope Appointment Booked (Invitation Sent)") + F317 = (202064, "F317", "No Post-Investigation Contact Made") + F32 = (209011, "F32", "Bowel scope Appointment Confirmed") + F33 = (208043, "F33", "Bowel scope Appointment Confirmed (with Letter)") + F34 = (204025, "F34", "Bowel scope Appointment Confirmation Letter Printed") + F35 = (206023, "F35", "Bowel scope Appointment Booked (Following SC Cancellation)") + F36 = ( + 209012, + "F36", + "Bowel scope Appointment Booked (Another Appointment Required)", + ) + F37 = (206024, "F37", "Bowel scope Appointment Booked (Screening Centre Rebook)") + F38 = (206025, "F38", "Bowel scope Appointment Booked (Subject Not Responded Yet)") + F39 = (206026, "F39", "Bowel scope Appointment Booked (Subject Rebook)") + F40 = (206027, "F40", "Bowel scope Appointment Cancelled (Non Response)") + F41 = (205024, "F41", "Subject letter for Non response Sent") + F42 = (205025, "F42", "GP letter for Non-response Sent") + F45 = (205026, "F45", "Bowel scope Bowel Prep Requested") + F46 = (205027, "F46", "Bowel scope Bowel Prep Sent") + F47 = (203156, "F47", "Screening Centre DNA bowel scope") + F50 = (206028, "F50", "Bowel scope Appointment Cancelled (Manual Cancellation)") + F51 = ( + 206029, + "F51", + "Bowel scope Appointment Cancellation Letter Sent (Manual cancellation)", + ) + F52 = (205139, "F52", "Bowel scope Appointment Cancelled (Subject not available)") + F53 = ( + 208093, + "F53", + "Bowel scope Appointment Re-allocated (Subject Not Responded Yet)", + ) + F54 = (208094, "F54", "Bowel scope Appointment Re-allocated (prior to issue)") + F55 = ( + 205140, + "F55", + "Bowel scope Appointment Cancelled (Insufficient availability)", + ) + F60 = (206030, "F60", "Bowel scope Appointment Cancelled (Not Suitable)") + F69 = (203023, "F69", "Book another bowel scope Appointment") + F70 = ( + 206031, + "F70", + "Another bowel scope Screening Appointment Required following investigation", + ) + F71 = ( + 206032, + "F71", + "Another bowel scope Screening Appointment Required (Not Responded Yet)", + ) + F710 = (205068, "F710", "Colonoscopy Assessment Appointment Attended") + F711 = ( + 205069, + "F711", + "Post-investigation Appointment Attended as Post-investigation", + ) + F712 = (202078, "F712", "Colonoscopy Assessment Appointment Attended") + F713 = (202079, "F713", "Colonoscopy Assessment Dataset Not Completed") + F714 = (202080, "F714", "Colonoscopy Assessment Complete") + F715 = ( + 202095, + "F715", + "Result Letter on Queue (No colonoscopy assessment appointment)", + ) + F716 = ( + 202096, + "F716", + "Result Letter on Queue (Colonoscopy assessment appointment attended)", + ) + F717 = ( + 204314, + "F717", + "GP Result Communication Created (Colonoscopy assessment appointment attended)", + ) + F718 = ( + 204315, + "F718", + "GP Result Communication Created (No colonoscopy assessment appointment)", + ) + F719 = ( + 204316, + "F719", + "Patient Result Letter Created (No colonoscopy assessment appointment)", + ) + F72 = (206033, "F72", "Another bowel scope Screening Appointment Required (Misc)") + F720 = ( + 204317, + "F720", + "Patient Result Letter Created (Colonoscopy assessment appointment attended)", + ) + F73 = (206034, "F73", "Bowel scope Appointment Booked (Following Investigation)") + F74 = (206035, "F74", "Bowel scope Appointment Booked (Misc)") + F75 = (206036, "F75", "Bowel scope Appointment Booked (Subject Not Responded Yet)") + F76 = ( + 204043, + "F76", + "Close bowel scope episode on Decline, prepare subject letter", + ) + F77 = ( + 206037, + "F77", + "Subject not available for offered appointment, prepare subject letter", + ) + F78 = ( + 206038, + "F78", + "Bowel scope Appointment Cancelled (Insufficient Availability)", + ) + F79 = (204044, "F79", "Close bowel scope episode on Decline, prepare GP letter") + F80 = ( + 204045, + "F80", + "Subject not available for offered appointment, prepare GP letter", + ) + F81 = ( + 204046, + "F81", + "Close bowel scope episode on Opt out of current episode, prepare patient letter", + ) + F82 = ( + 204047, + "F82", + "Close bowel scope episode on Opt out of current episode, prepare GP letter", + ) + F83 = (205000, "F83", "Self-referred for bowel scope Screening") + F84 = (206039, "F84", "Bowel scope Appointment Booked (Self-refer)") + F85 = ( + 209013, + "F85", + "Subject not available for offered appointment, GP letter printed", + ) + F86 = (205028, "F86", "Bowel scope Appointment Booked (Self-referral)") + F87 = ( + 205029, + "F87", + "Bowel scope Appointment Confirmed (Self-referral with Letter)", + ) + F88 = ( + 203181, + "F88", + "Close bowel scope episode due to incorrect date of birth, prepare subject letter", + ) + F9 = (200651, "F9", "Bowel scope Screening Pre-invitation sent") + F92 = (205001, "F92", "Close bowel scope screening episode") + F93 = (209016, "F93", "Request Returned/undelivered mail letter") + F95 = (208063, "F95", "Assessed NOT suitable for bowel scope") + F96 = (208064, "F96", "Assessed NOT suitable for bowel scope (subject) letter sent") + F97 = (208065, "F97", "Assessed NOT suitable for bowel scope (GP) letter sent") + F98 = ( + 208066, + "F98", + "Unable to contact subject to complete BS Suitability Assessment", + ) + F99 = ( + 208067, + "F99", + "Unable to contact subject to complete bowel scope Suitability Assessment (subject) letter sent", + ) + G1 = (307067, "G1", "Selected for Lynch Surveillance (Prevalent)") + G2 = (307068, "G2", "Lynch Pre-invitation Sent") + G3 = (305634, "G3", "Lynch Surveillance Assessment Appointment Required") + G4 = (307126, "G4", "Selected for Lynch Surveillance (Self-referral)") + G5 = (307076, "G5", "Selected for Lynch Surveillance (Incident)") + G6 = (307081, "G6", "Review suitability for Lynch Surveillance") + G7 = (307085, "G7", "Not suitable for Lynch Surveillance (Recent Colonoscopy)") + G8 = ( + 307095, + "G8", + "Not suitable for Lynch Surveillance (Recent Colonoscopy) patient letter sent", + ) + G9 = (307086, "G9", "Not suitable for Lynch Surveillance (Incorrect Diagnosis)") + G10 = ( + 307096, + "G10", + "Not suitable for Lynch Surveillance (Incorrect Diagnosis) patient letter sent", + ) + G11 = ( + 307097, + "G11", + "Not suitable for Lynch Surveillance (Incorrect Diagnosis) GP letter sent", + ) + G12 = (307102, "G12", "Not suitable for Lynch Surveillance (Clinical Reason)") + G13 = ( + 307103, + "G13", + "Not suitable for Lynch Surveillance (Clinical Reason) patient letter sent", + ) + G14 = ( + 307104, + "G14", + "Not suitable for Lynch Surveillance (Clinical Reason) GP letter sent", + ) + G92 = (305691, "G92", "Close Lynch Episode via Interrupt") + J1 = (202465, "J1", "Subsequent Assessment Appointment Required") + J10 = (11139, "J10", "Attended Colonoscopy Assessment Appointment") + J11 = ( + 11140, + "J11", + "1st Colonoscopy Assessment Appointment Non-attendance (Patient)", + ) + J12 = (11141, "J12", "Appointment Cancelled following a DNA (Screening Centre)") + J13 = ( + 11142, + "J13", + "Appointment Cancelled following a DNA (Patient to Reschedule)", + ) + J14 = (11143, "J14", "Appointment Cancelled following a DNA (Patient to Consider)") + J15 = (11144, "J15", "Not Suitable for Diagnostic Tests") + J16 = (11145, "J16", "Patient Discharge Sent (Unsuitable for Diagnostic Tests)") + J17 = (11146, "J17", "GP Discharge Sent (Unsuitable for Diagnostic Tests)") + J18 = (11147, "J18", "Appointment Requested (Screening Centre Cancellation Letter)") + J19 = ( + 11148, + "J19", + "Appointment Requested following a DNA (Screening Centre Cancel Letter)", + ) + J2 = (11149, "J2", "Appointment Cancellation (Screening Centre)") + J20 = (11150, "J20", "Appointment Requested (Patient to Reschedule Letter)") + J21 = ( + 11151, + "J21", + "Appointment Requested following a DNA (Patient to Reschedule Letter)", + ) + J22 = (11152, "J22", "Appointment Cancellation letter sent (Patient to Consider)") + J23 = ( + 11153, + "J23", + "Appointment Cancellation letter sent following a DNA (Patient to Consider)", + ) + J24 = (11154, "J24", "Screening Centre Discharge Patient") + J25 = (11155, "J25", "Patient discharge sent (Screening Centre discharge patient)") + J26 = (11156, "J26", "GP Discharge letter sent (Discharge by Screening centre)") + J27 = (11157, "J27", "Appointment Non-attendance Letter Sent (Patient)") + J28 = (11158, "J28", "Appointment Non-attendance (Screening Centre)") + J29 = ( + 11159, + "J29", + "Appointment Non-attendance following a DNA (Screening Centre)", + ) + J3 = (11160, "J3", "Patient Refused Colonoscopy Assessment Appointment") + J30 = (11161, "J30", "Appointment Requested (SC Non-attendance Letter)") + J31 = ( + 11162, + "J31", + "Appointment Requested following a DNA (SC Non-attendance Letter)", + ) + J32 = (205214, "J32", "Colonoscopy Assessment Appointment Request (Redirected)") + J33 = (205222, "J33", "Colonoscopy Assessment Appointment Requested (Redirected)") + J34 = (305443, "J34", "Subsequent Appointment Requested") + J35 = (305444, "J35", "Subsequent Appointment Booked, letter sent") + J36 = (305445, "J36", "Subsequent Appointment Non-attendance (Patient)") + J37 = (305446, "J37", "Subsequent Appointment Requested following a DNA") + J38 = (305447, "J38", "Subsequent Appointment Booked, letter sent following a DNA") + J4 = (11163, "J4", "Appointment Cancellation (Patient to Consider)") + J40 = ( + 305448, + "J40", + "Subsequent Appointment Non-attendance (Patient) following a DNA", + ) + J5 = (11164, "J5", "Appointment Cancellation (Patient to Reschedule)") + J7 = (202466, "J7", "Colonoscopy Assessment Dataset Completed") + J8 = ( + 11165, + "J8", + "Patient discharge sent (refused colonoscopy assessment appointment)", + ) + J9 = ( + 11166, + "J9", + "GP discharge letter sent (refusal of colonoscopy assessment appointment)", + ) + K105 = (11167, "K105", "Waiting for Confirmation of Closure") + K188 = (11168, "K188", "New Kit Requested") + K189 = (11169, "K189", "New Kit Sent") + L204 = (11291, "L204", "Letter Queued") + L205 = (11292, "L205", "Communication Cancelled") + L206 = (11297, "L206", "Supplementary Letter Printed") + L207 = (11298, "L207", "Letter Reprinted") + L208 = (11299, "L208", "Redundant Letter Printed") + M1 = (305757, "M1", "NHS App message requested") + N112 = (11170, "N112", "Test Spoilt (Weak Positive & Normal)") + N113 = (11171, "N113", "Technical Fail (Weak Positive & Normal)") + N114 = (11172, "N114", "Test Spoilt, Assistance Required (Weak Positive & Normal)") + N115 = (11173, "N115", "Retest Kit Sent (Spoilt; Weak Positive & Normal)") + N116 = (11174, "N116", "Retest Kit Sent (Assisted; Weak Positive & Normal)") + N117 = (11175, "N117", "Retest Kit Sent (Technical Fail; Weak Positive & Normal)") + N118 = (11176, "N118", "Kit Returned and Logged (Spoilt; Weak Positive & Normal)") + N119 = (11177, "N119", "Kit Returned and Logged (Assisted; Weak Positive & Normal)") + N120 = ( + 11178, + "N120", + "Kit Returned and Logged (Technical Fail; Weak Positive & Normal)", + ) + N121 = ( + 11179, + "N121", + "Reminder of Retest Kit Sent (Spoilt; Weak Positive & Normal)", + ) + N122 = ( + 11180, + "N122", + "Reminder of Retest Kit Sent (Assisted; Weak Positive & Normal)", + ) + N123 = ( + 11181, + "N123", + "Reminder of Retest Kit Sent (Technical Fail; Weak Positive & Normal)", + ) + N124 = ( + 11182, + "N124", + "GP Discharge for Non-response Sent (Spoilt Retest Kit; Weak Positive & Normal)", + ) + N125 = ( + 11183, + "N125", + "GP Discharge for Non-response Sent (Assisted Retest Kit; Weak Positive & Normal)", + ) + N126 = ( + 11184, + "N126", + "GP Discharge for Non-response Sent (Technical Fail Retest Kit; Weak Positive & Normal)", + ) + N129 = (11185, "N129", "Technical Fail (Weak Positive & Normal) (Spoilt History)") + N137 = ( + 11186, + "N137", + "Retest Kit Sent (Technical Fail; Weak Positive & Normal) (Spoilt History)", + ) + N140 = ( + 11187, + "N140", + "Kit Returned and Logged (Technical Fail; Weak Positive & Normal) (Spoilt History)", + ) + N143 = ( + 11188, + "N143", + "Reminder of Retest Kit Sent (Technical Fail; Weak Positive & Normal) (Spoilt History)", + ) + N160 = ( + 11189, + "N160", + "Weak Positive and One Normal, Waiting for Screening Centre Assistance", + ) + N161 = ( + 11190, + "N161", + "Weak Positive and One Normal, Waiting for Programme Hub Assistance", + ) + N164 = ( + 11191, + "N164", + "GP Discharge Sent (Non-acceptance of Assistance; Weak Positive & Normal)", + ) + N174 = ( + 11192, + "N174", + "GP Discharge for Non-response Sent (Technical Fail Retest Kit; Weak Positive & Normal) (Spoilt History)", + ) + N179 = (11193, "N179", "Assisted (Weak Positive & Normal) New Kit Required") + N182 = (11194, "N182", "Assisted (Weak Positive & Normal) No Response") + P202 = (11195, "P202", "Waiting Completion of Spur Events") + P300 = (160212, "P300", "Defer, Pending Further Assessment") + P88 = (11196, "P88", "Waiting Further Information") + Q0 = (160236, "Q0", "Selected for a 30 Day Questionnaire") + Q1 = ( + 160237, + "Q1", + "30 Day Questionnaire (Screening: Endoscopy only) letter created", + ) + Q2 = ( + 160238, + "Q2", + "30 Day Questionnaire (Screening: Endoscopy & Radiology) letter created", + ) + Q208 = (160230, "Q208", "Discard Diagnostic Test") + Q209 = (20239, "Q209", "30 Day Questionnaire printed") + Q3 = ( + 160239, + "Q3", + "30 Day Questionnaire (Screening: Radiology only) letter created", + ) + Q4 = ( + 160240, + "Q4", + "30 Day Questionnaire (Surveillance: Endoscopy only) letter created", + ) + Q5 = ( + 160241, + "Q5", + "30 Day Questionnaire (Surveillance: Endoscopy & Radiology) letter created", + ) + Q6 = ( + 160242, + "Q6", + "30 Day Questionnaire (Surveillance: Radiology only) letter created", + ) + RedirectedWithinEpisode = ( + 11286, + "RedirectedWithinEpisode", + "Redirected within Episode", + ) + REPRODUCELETT = (11293, "REPRODUCELETT", "Re-produced Letter from Archive") + S1 = (11197, "S1", "Selected for Screening") + S10 = (11198, "S10", "Invitation & Test Kit Sent") + S11 = (11199, "S11", "Retest Kit Sent (Spoilt)") + S12 = (11200, "S12", "Retest Kit Sent (Assisted)") + S127 = (11201, "S127", "Technical Fail (Spoilt History)") + S13 = (11202, "S13", "Retest Kit Sent (Technical Fail)") + S135 = (11203, "S135", "Retest Kit Sent (Technical Fail) (Spoilt History)") + S138 = (11204, "S138", "Kit Returned and Logged (Technical Fail) (Spoilt History)") + S141 = ( + 11205, + "S141", + "Reminder of Retest Kit Sent (Technical Fail) (Spoilt History)", + ) + S157 = (11206, "S157", "Pre-invitation Sent (Opt-in)") + S158 = (11207, "S158", "Subject Discharge Sent (Normal)") + S159 = (11208, "S159", "GP Discharge Sent (Normal)") + S162 = (11209, "S162", "GP Discharge Sent (Non-acceptance of Assistance)") + S175 = ( + 11210, + "S175", + "GP Discharge for Non-response Sent (Technical Fail Retest Kit) (Spoilt History)", + ) + S177 = (11211, "S177", "Assisted, New Kit Required") + S180 = (11212, "S180", "Assisted, No Response") + S19 = (11213, "S19", "Reminder of Initial Test Sent") + S192 = (11214, "S192", "Subject Discharge Sent (Normal; Weak Positive)") + S193 = (11215, "S193", "GP Discharge Sent (Normal; Weak Positive)") + S195 = (200520, "S195", "Receipt of Self-referral kit") + S2 = (11216, "S2", "Normal") + S20 = (11217, "S20", "Reminder of Retest Kit Sent (Spoilt)") + S201 = (11218, "S201", "Follow Up Questionnaire Sent (Normal)") + S21 = (11219, "S21", "Reminder of Retest Kit Sent (Assisted)") + S22 = (11220, "S22", "Reminder of Retest Kit Sent (Technical Fail)") + S23 = (306108, "S23", "Reminder of Retest Kit Sent (Screening Incident)") + S24 = (306225, "S24", "Reminder of Replacement Kit Sent (Screening Incident)") + S3 = (11221, "S3", "Test Spoilt") + S4 = (11222, "S4", "Test Spoilt (Assistance Required)") + S43 = (11223, "S43", "Kit Returned and Logged (Initial Test)") + S44 = (11224, "S44", "GP Discharge for Non-response Sent (Initial Test") + S46 = (11225, "S46", "Kit Returned and Logged (Spoilt") + S47 = (11226, "S47", "GP Discharge for Non-response Sent (Spoilt Retest Kit") + S49 = (11227, "S49", "Kit Returned and Logged (Assisted") + S5 = (11228, "S5", "Technical Fail") + S50 = (11229, "S50", "GP Discharge for Non-response Sent (Assisted Retest Kit") + S51 = ( + 11230, + "S51", + "GP Discharge for Non-response Sent (Technical Fail Retest Kit", + ) + S52 = (11231, "S52", "Kit Returned and Logged (Technical Fail") + S53 = (306107, "S53", "Kit Returned and Logged (Screening Incident") + S54 = ( + 306109, + "S54", + "GP Discharge for Non-response Sent (Screening Incident Retest", + ) + S55 = ( + 306193, + "S55", + "GP Discharge for Non-response Sent (Screening Incident Replacement", + ) + S56 = (11232, "S56", "2nd Normal (Weak Positive & Normal") + S61 = (160183, "S61", "Normal (No Abnormalities Found") + S7 = (306104, "S7", "Result Invalidated (Screening Incident") + S71 = (306106, "S71", "Retest Kit Sent (Screening Incident") + S72 = (306105, "S72", "Test Kit Invalidated (Screening Incident") + S73 = (306110, "S73", "Replacement Kit Sent (Screening Incident") + S83 = (11234, "S83", "Selected for Screening (Self-referral") + S84 = (11235, "S84", "Invitation and Test Kit Sent (Self-referral") + S9 = (11236, "S9", "Pre-invitation Sent") + S92 = (11237, "S92", "Close Screening Episode via Interrupt") + S94 = (11238, "S94", "Selected for Screening (Opt-in") + S95 = (11239, "S95", "Waiting for Screening Centre Assistance") + S96 = (11240, "S96", "Waiting for Programme Hub Assistance") + U128 = (11241, "U128", "Technical Fail (Weak Positive) (Spoilt History") + U130 = (11242, "U130", "Normal (Weak Positive) (Spoilt History") + U131 = (11243, "U131", "Weak Positive (Spoilt History") + U132 = (11244, "U132", "Retest Kit Sent (Weak Positive & Normal) (Spoilt History") + U133 = ( + 11245, + "U133", + "Kit Returned and Logged (Weak Positive & Normal) (Spoilt History", + ) + U134 = ( + 11246, + "U134", + "Reminder of Retest Kit Sent (Weak Positive & Normal) (Spoilt History", + ) + U136 = ( + 11247, + "U136", + "Retest Kit Sent (Technical Fail; Weak Positive) (Spoilt History", + ) + U139 = ( + 11248, + "U139", + "Kit Returned and Logged (Technical Fail; Weak Positive) (Spoilt History", + ) + U14 = (11249, "U14", "Retest Kit Sent (Weak Positive") + U142 = ( + 11250, + "U142", + "Reminder of Retest Kit Sent (Technical Fail; Weak Positive) (Spoilt History", + ) + U144 = (11251, "U144", "Retest Kit Sent (Weak Positive) (Spoilt History") + U145 = (11252, "U145", "Kit Returned and Logged (Weak Positive) (Spoilt History") + U146 = ( + 11253, + "U146", + "Reminder of Retest Kit Sent (Weak Positive) (Spoilt History", + ) + U15 = (11254, "U15", "Retest Kit Sent (Weak Positive & Normal") + U163 = ( + 11255, + "U163", + "GP Discharge Sent (Non-acceptance of Assistance; Weak Positive", + ) + U176 = ( + 11256, + "U176", + "GP Discharge for Non-response Sent (Technical Fail Retest Kit; Weak Positive) (Spoilt History", + ) + U178 = (11257, "U178", "Assisted (Weak Positive) New Kit Required") + U181 = (11258, "U181", "Assisted (Weak Positive) No Response") + U186 = ( + 11259, + "U186", + "GP Discharge for Non-response Sent (Weak Positive Retest Kit) (Spoilt History", + ) + U187 = ( + 11260, + "U187", + "GP Discharge for Non-response Sent (Weak Positive & Normal Retest Kit) (Spoilt History", + ) + U200 = (204106, "U200", "FIT Device Linked to Episode") + U23 = (11261, "U23", "Reminder of Retest Kit Sent (Weak Positive") + U24 = (11262, "U24", "Reminder of Retest Kit Sent (Weak Positive & Normal") + U54 = (11263, "U54", "GP Discharge for Non-response Sent (Weak Positive Retest Kit") + U55 = (11264, "U55", "Kit Returned and Logged (Weak Positive") + U57 = ( + 11265, + "U57", + "GP Discharge for Non-response Sent (Weak Positive & Normal Retest Kit", + ) + U58 = (11266, "U58", "Kit Returned and Logged (Weak Positive & Normal") + U6 = (11267, "U6", "Weak Positive") + U66 = (11268, "U66", "Test Spoilt (Weak Positive") + U67 = (11269, "U67", "Technical Fail (Weak Positive") + U68 = (11270, "U68", "Test Spoilt, Assistance Required (Weak Positive") + U69 = (11271, "U69", "Retest Kit (Spoilt; Weak Positive") + U7 = (11272, "U7", "Normal (Weak Positive") + U70 = (11273, "U70", "Reminder of Retest Kit Sent (Spoilt; Weak Positive") + U71 = ( + 11274, + "U71", + "GP Discharge for Non-response Sent (Spoilt Retest Kit; Weak Positive", + ) + U72 = (11275, "U72", "Kit Returned and Logged (Spoilt; Weak Positive") + U74 = (11276, "U74", "Kit Returned and Logged (Assisted; Weak Positive") + U75 = (11277, "U75", "Retest Kit Sent (Assisted; Weak Positive") + U76 = (11278, "U76", "Reminder of Retest Kit Sent (Assisted; Weak Positive") + U77 = ( + 11279, + "U77", + "GP Discharge for Non-response Sent (Assisted Retest Kit; Weak Positive", + ) + U78 = (11280, "U78", "Retest Kit Sent (Technical Fail; Weak Positive") + U79 = (11281, "U79", "Reminder of Retest Kit Sent (Technical Fail; Weak Positive") + U80 = ( + 11282, + "U80", + "GP Discharge for Non-response Sent (Technical Fail Retest Kit; Weak Positive", + ) + U81 = (11283, "U81", "Kit Returned and Logged (Technical Fail; Weak Positive") + U97 = (11284, "U97", "Weak Positive, Waiting for Screening Centre Assistance") + U98 = (11285, "U98", "Weak Positive, Waiting for Programme Hub Assistance") + + def __init__(self, valid_value_id: int, allowed_value: str, description: str): + """ + Initialize an EventStatusType enum member. + + Args: + valid_value_id (int): The unique identifier for the event status. + allowed_value (str): The event status code. + description (str): The human-readable description of the event status. + """ + self._id = valid_value_id + self._code = allowed_value + self._description = description + + @property + def id(self) -> int: + """ + Returns the unique identifier for the event status. + + Returns: + int: The event status ID. + """ + return self._id + + @property + def code(self) -> str: + """ + Returns the event status code. + + Returns: + str: The event status code. + """ + return self._code + + @property + def description(self) -> str: + """ + Returns the human-readable description of the event status. + + Returns: + str: The description. + """ + return self._description + + @classmethod + def get_by_id(cls, id_: int) -> Optional["EventStatusType"]: + """ + Returns the enum member matching the given id. + + Args: + id_ (int): The event status ID to search for. + + Returns: + Optional[EventStatusType]: The matching enum member, or None if not found. + """ + return next((e for e in cls if e.id == id_), None) + + @classmethod + def get_by_code(cls, code: str) -> Optional["EventStatusType"]: + """ + Returns the enum member matching the given code. + + Args: + code (str): The event status code to search for. + + Returns: + Optional[EventStatusType]: The matching enum member, or None if not found. + """ + return next((e for e in cls if e.code == code), None) + + @classmethod + def get_by_description(cls, description: str) -> Optional["EventStatusType"]: + """ + Returns the enum member matching the given description. + + Args: + description (str): The event status description to search for. + + Returns: + Optional[EventStatusType]: The matching enum member, or None if not found. + """ + return next((e for e in cls if e.description == description), None) diff --git a/classes/gender_type.py b/classes/gender_type.py new file mode 100644 index 00000000..e1b9fba4 --- /dev/null +++ b/classes/gender_type.py @@ -0,0 +1,90 @@ +from enum import Enum +from typing import Optional + + +class GenderType(Enum): + """ + Enum representing gender types for a subject. + + Members: + MALE: Male gender (valid_value_id=130, redefined_value=1, allowed_value="M") + FEMALE: Female gender (valid_value_id=131, redefined_value=2, allowed_value="F") + INDETERMINATE: Indeterminate gender (valid_value_id=132, redefined_value=9, allowed_value="I") + NOT_KNOWN: Not known gender (valid_value_id=160, redefined_value=0, allowed_value="U") + """ + + MALE = (130, 1, "M") + FEMALE = (131, 2, "F") + INDETERMINATE = (132, 9, "I") + NOT_KNOWN = (160, 0, "U") + + def __init__(self, valid_value_id: int, redefined_value: int, allowed_value: str): + """ + Initialize a GenderType enum member. + + Args: + valid_value_id (int): The unique identifier for the gender type. + redefined_value (int): The redefined value for the gender type. + allowed_value (str): The string representation of the gender type. + """ + self._valid_value_id = valid_value_id + self._redefined_value = redefined_value + self._allowed_value = allowed_value + + @property + def valid_value_id(self) -> int: + """ + Returns the unique identifier for the gender type. + + Returns: + int: The valid value ID. + """ + return self._valid_value_id + + @property + def redefined_value(self) -> int: + """ + Returns the redefined value for the gender type. + + Returns: + int: The redefined value. + """ + return self._redefined_value + + @property + def allowed_value(self) -> str: + """ + Returns the string representation of the gender type. + + Returns: + str: The allowed value. + """ + return self._allowed_value + + @classmethod + def by_valid_value_id(cls, id_: int) -> Optional["GenderType"]: + """ + Returns the GenderType enum member matching the given valid value ID. + + Args: + id_ (int): The valid value ID to search for. + + Returns: + Optional[GenderType]: The matching enum member, or None if not found. + """ + return next((item for item in cls if item.valid_value_id == id_), None) + + @classmethod + def by_redefined_value(cls, redefined_value: int) -> Optional["GenderType"]: + """ + Returns the GenderType enum member matching the given redefined value. + + Args: + redefined_value (int): The redefined value to search for. + + Returns: + Optional[GenderType]: The matching enum member, or None if not found. + """ + return next( + (item for item in cls if item.redefined_value == redefined_value), None + ) diff --git a/classes/has_date_of_death_removal.py b/classes/has_date_of_death_removal.py new file mode 100644 index 00000000..79e4d0f2 --- /dev/null +++ b/classes/has_date_of_death_removal.py @@ -0,0 +1,39 @@ +class HasDateOfDeathRemoval: + """ + Utility class for mapping binary filter for the presence of a date-of-death removal record. + + This class provides: + - Logical flags for "yes" and "no" to indicate if a date-of-death removal record exists. + - A method to convert a description to a valid flag. + + Methods: + from_description(description: str) -> str: + Returns the logical flag ("yes" or "no") for a given description. + Raises ValueError if the description is not recognized. + """ + + YES = "yes" + NO = "no" + + _valid_values = {YES, NO} + + @classmethod + def from_description(cls, description: str) -> str: + """ + Returns the logical flag ("yes" or "no") for a given description. + + Args: + description (str): The description to check (e.g., "yes" or "no"). + + Returns: + str: The logical flag ("yes" or "no"). + + Raises: + ValueError: If the description is not recognized. + """ + key = description.strip().lower() + if key not in cls._valid_values: + raise ValueError( + f"Invalid value for date-of-death removal filter: '{description}'" + ) + return key diff --git a/classes/has_gp_practice.py b/classes/has_gp_practice.py new file mode 100644 index 00000000..351316f3 --- /dev/null +++ b/classes/has_gp_practice.py @@ -0,0 +1,47 @@ +from enum import Enum + + +class HasGPPractice(Enum): + """ + Enum representing whether a subject has a GP practice and its status. + + Members: + NO: No GP practice. + YES_ACTIVE: Has an active GP practice. + YES_INACTIVE: Has an inactive GP practice. + + Methods: + by_description(description: str) -> Optional[HasGPPractice]: + Returns the enum member matching the given description, or None if not found. + get_description() -> str: + Returns the string description of the enum member. + """ + + NO = "no" + YES_ACTIVE = "yes - active" + YES_INACTIVE = "yes - inactive" + + @classmethod + def by_description(cls, description: str): + """ + Returns the enum member matching the given description. + + Args: + description (str): The description to search for. + + Returns: + Optional[HasGPPractice]: The matching enum member, or None if not found. + """ + for member in cls: + if member.value == description: + return member + return None + + def get_description(self): + """ + Returns the string description of the enum member. + + Returns: + str: The description value. + """ + return self.value diff --git a/classes/has_unprocessed_sspi_updates.py b/classes/has_unprocessed_sspi_updates.py new file mode 100644 index 00000000..c33fd660 --- /dev/null +++ b/classes/has_unprocessed_sspi_updates.py @@ -0,0 +1,46 @@ +from enum import Enum +from typing import Optional, Dict + + +class HasUnprocessedSSPIUpdates(Enum): + """ + Enum representing whether a subject has unprocessed SSPI (Screening Service Provider Interface) updates. + + Members: + NO: No unprocessed SSPI updates. + YES: Has unprocessed SSPI updates. + + Methods: + by_description(description: str) -> Optional[HasUnprocessedSSPIUpdates]: + Returns the enum member matching the given description, or None if not found. + get_description() -> str: + Returns the string description of the enum member. + """ + + NO = "no" + YES = "yes" + + @classmethod + def by_description(cls, description: str) -> Optional["HasUnprocessedSSPIUpdates"]: + """ + Returns the enum member matching the given description. + + Args: + description (str): The description to search for. + + Returns: + Optional[HasUnprocessedSSPIUpdates]: The matching enum member, or None if not found. + """ + for item in cls: + if item.value == description: + return item + return None + + def get_description(self) -> str: + """ + Returns the string description of the enum member. + + Returns: + str: The description value. + """ + return self.value diff --git a/classes/has_user_dob_update.py b/classes/has_user_dob_update.py new file mode 100644 index 00000000..f36dbd68 --- /dev/null +++ b/classes/has_user_dob_update.py @@ -0,0 +1,46 @@ +from enum import Enum +from typing import Optional, Dict + + +class HasUserDobUpdate(Enum): + """ + Enum representing whether a subject has a user-initiated date of birth update. + + Members: + NO: No user DOB update. + YES: Has a user DOB update. + + Methods: + by_description(description: str) -> Optional[HasUserDobUpdate]: + Returns the enum member matching the given description, or None if not found. + get_description() -> str: + Returns the string description of the enum member. + """ + + NO = "no" + YES = "yes" + + @classmethod + def by_description(cls, description: str) -> Optional["HasUserDobUpdate"]: + """ + Returns the enum member matching the given description. + + Args: + description (str): The description to search for. + + Returns: + Optional[HasUserDobUpdate]: The matching enum member, or None if not found. + """ + for item in cls: + if item.value == description: + return item + return None + + def get_description(self) -> str: + """ + Returns the string description of the enum member. + + Returns: + str: The description value. + """ + return self.value diff --git a/classes/intended_extent_type.py b/classes/intended_extent_type.py new file mode 100644 index 00000000..2717f41c --- /dev/null +++ b/classes/intended_extent_type.py @@ -0,0 +1,94 @@ +class IntendedExtentType: + """ + Utility class for mapping intended extent values to nullability flags or valid value IDs. + + This class provides: + - Logical flags for "null" and "not null" to indicate nullability. + - A mapping from descriptive intended extent labels (e.g., "full", "partial", "none") to internal valid value IDs. + - Methods to convert descriptions to flags or IDs, and to get a description from a sentinel value. + + Methods: + from_description(description: str) -> str | int: + Returns the logical flag ("null"/"not null") or the valid value ID for a given description. + Raises ValueError if the description is not recognized. + + get_id(description: str) -> int: + Returns the valid value ID for a given intended extent description. + Raises ValueError if the description is not recognized or has no ID. + + get_description(sentinel: str) -> str: + Returns the string description for a sentinel value ("null" or "not null"). + Raises ValueError if the sentinel is not recognized. + """ + + NULL = "null" + NOT_NULL = "not null" + + _label_to_id = { + "full": 9201, + "partial": 9202, + "none": 9203, + # Add others as needed + } + + _null_flags = {NULL, NOT_NULL} + + @classmethod + def from_description(cls, description: str): + """ + Returns the logical flag ("null"/"not null") or the valid value ID for a given description. + + Args: + description (str): The intended extent description. + + Returns: + str | int: The logical flag ("null"/"not null") or the valid value ID. + + Raises: + ValueError: If the description is not recognized. + """ + key = description.strip().lower() + if key in cls._null_flags: + return key + if key in cls._label_to_id: + return cls._label_to_id[key] + raise ValueError(f"Unknown intended extent: '{description}'") + + @classmethod + def get_id(cls, description: str) -> int: + """ + Returns the valid value ID for a given intended extent description. + + Args: + description (str): The intended extent description. + + Returns: + int: The valid value ID. + + Raises: + ValueError: If the description is not recognized or has no ID. + """ + key = description.strip().lower() + if key not in cls._label_to_id: + raise ValueError(f"No ID available for intended extent: '{description}'") + return cls._label_to_id[key] + + @classmethod + def get_description(cls, sentinel: str) -> str: + """ + Returns the string description for a sentinel value ("null" or "not null"). + + Args: + sentinel (str): The sentinel value to describe. + + Returns: + str: The string description ("NULL" or "NOT NULL"). + + Raises: + ValueError: If the sentinel is not recognized. + """ + if sentinel == cls.NULL: + return "NULL" + if sentinel == cls.NOT_NULL: + return "NOT NULL" + raise ValueError(f"Invalid sentinel: '{sentinel}'") diff --git a/classes/invited_since_age_extension.py b/classes/invited_since_age_extension.py new file mode 100644 index 00000000..9fe79c8b --- /dev/null +++ b/classes/invited_since_age_extension.py @@ -0,0 +1,39 @@ +class InvitedSinceAgeExtension: + """ + Utility class for mapping subject invitation criteria based on age extension presence. + + This class provides: + - Logical flags for "yes" and "no" to indicate if a subject was invited since the age extension. + - A method to convert a description to a valid flag. + + Methods: + from_description(description: str) -> str: + Returns the logical flag ("yes" or "no") for a given description. + Raises ValueError if the description is not recognized. + """ + + YES = "yes" + NO = "no" + + _valid_values = {YES, NO} + + @classmethod + def from_description(cls, description: str) -> str: + """ + Returns the logical flag ("yes" or "no") for a given description. + + Args: + description (str): The description to check (e.g., "yes" or "no"). + + Returns: + str: The logical flag ("yes" or "no"). + + Raises: + ValueError: If the description is not recognized. + """ + key = description.strip().lower() + if key not in cls._valid_values: + raise ValueError( + f"Invalid invited-since-age-extension flag: '{description}'" + ) + return key diff --git a/classes/latest_episode_has_dataset.py b/classes/latest_episode_has_dataset.py new file mode 100644 index 00000000..c901acfb --- /dev/null +++ b/classes/latest_episode_has_dataset.py @@ -0,0 +1,45 @@ +class LatestEpisodeHasDataset: + """ + Utility class for interpreting the presence and completion status of datasets in the latest episode. + + This class provides: + - Logical flags for "no", "yes_incomplete", "yes_complete", and "past" to indicate dataset status. + - A method to convert a description to a valid flag. + + Members: + NO: No dataset present. + YES_INCOMPLETE: Dataset present but incomplete. + YES_COMPLETE: Dataset present and complete. + PAST: Dataset present in a past episode. + + Methods: + from_description(description: str) -> str: + Returns the logical flag for a given description. + Raises ValueError if the description is not recognized. + """ + + NO = "no" + YES_INCOMPLETE = "yes_incomplete" + YES_COMPLETE = "yes_complete" + PAST = "past" + + _valid_values = {NO, YES_INCOMPLETE, YES_COMPLETE, PAST} + + @classmethod + def from_description(cls, description: str) -> str: + """ + Returns the logical flag for a given description. + + Args: + description (str): The description to check (e.g., "no", "yes_incomplete", "yes_complete", "past"). + + Returns: + str: The logical flag matching the description. + + Raises: + ValueError: If the description is not recognized. + """ + key = description.strip().lower() + if key not in cls._valid_values: + raise ValueError(f"Unknown dataset status: '{description}'") + return key diff --git a/classes/latest_episode_latest_investigation_dataset.py b/classes/latest_episode_latest_investigation_dataset.py new file mode 100644 index 00000000..ba43f0df --- /dev/null +++ b/classes/latest_episode_latest_investigation_dataset.py @@ -0,0 +1,57 @@ +class LatestEpisodeLatestInvestigationDataset: + """ + Utility class for mapping descriptive investigation filter criteria to internal constants. + + This class is used to drive investigation dataset filtering in the latest episode. + + Members: + NONE: No investigation dataset. + COLONOSCOPY_NEW: New colonoscopy dataset. + LIMITED_COLONOSCOPY_NEW: New limited colonoscopy dataset. + FLEXIBLE_SIGMOIDOSCOPY_NEW: New flexible sigmoidoscopy dataset. + CT_COLONOGRAPHY_NEW: New CT colonography dataset. + ENDOSCOPY_INCOMPLETE: Incomplete endoscopy dataset. + RADIOLOGY_INCOMPLETE: Incomplete radiology dataset. + + Methods: + from_description(description: str) -> str: + Returns the internal constant for a given description. + Raises ValueError if the description is not recognized. + """ + + NONE = "none" + COLONOSCOPY_NEW = "colonoscopy_new" + LIMITED_COLONOSCOPY_NEW = "limited_colonoscopy_new" + FLEXIBLE_SIGMOIDOSCOPY_NEW = "flexible_sigmoidoscopy_new" + CT_COLONOGRAPHY_NEW = "ct_colonography_new" + ENDOSCOPY_INCOMPLETE = "endoscopy_incomplete" + RADIOLOGY_INCOMPLETE = "radiology_incomplete" + + _valid_values = { + NONE, + COLONOSCOPY_NEW, + LIMITED_COLONOSCOPY_NEW, + FLEXIBLE_SIGMOIDOSCOPY_NEW, + CT_COLONOGRAPHY_NEW, + ENDOSCOPY_INCOMPLETE, + RADIOLOGY_INCOMPLETE, + } + + @classmethod + def from_description(cls, description: str) -> str: + """ + Returns the internal constant for a given description. + + Args: + description (str): The description to check. + + Returns: + str: The internal constant matching the description. + + Raises: + ValueError: If the description is not recognized. + """ + key = description.strip().lower() + if key not in cls._valid_values: + raise ValueError(f"Unknown investigation dataset filter: '{description}'") + return key diff --git a/classes/lynch_due_date_reason_type.py b/classes/lynch_due_date_reason_type.py new file mode 100644 index 00000000..302863cd --- /dev/null +++ b/classes/lynch_due_date_reason_type.py @@ -0,0 +1,46 @@ +class LynchDueDateReasonType: + """ + Utility class for mapping Lynch surveillance due date reason descriptions to IDs and symbolic types. + + This class provides: + - Logical flags for "null", "not_null", and "unchanged" to indicate symbolic types. + - A mapping from descriptive reason labels (e.g., "holiday", "clinical request", "external delay") to internal valid value IDs. + - A method to convert descriptions to flags or IDs. + + Methods: + from_description(description: str) -> str | int: + Returns the logical flag ("null", "not_null", "unchanged") or the valid value ID for a given description. + Raises ValueError if the description is not recognized. + """ + + NULL = "null" + NOT_NULL = "not_null" + UNCHANGED = "unchanged" + + _label_to_id = { + "holiday": 9801, + "clinical request": 9802, + "external delay": 9803, + # Extend as needed + } + + @classmethod + def from_description(cls, description: str): + """ + Returns the logical flag ("null", "not_null", "unchanged") or the valid value ID for a given description. + + Args: + description (str): The Lynch due date reason description. + + Returns: + str | int: The symbolic flag or the valid value ID. + + Raises: + ValueError: If the description is not recognized. + """ + key = description.strip().lower() + if key in {cls.NULL, cls.NOT_NULL, cls.UNCHANGED}: + return key + if key in cls._label_to_id: + return cls._label_to_id[key] + raise ValueError(f"Unknown Lynch due date change reason: '{description}'") diff --git a/classes/lynch_incident_episode_type.py b/classes/lynch_incident_episode_type.py new file mode 100644 index 00000000..c396caa1 --- /dev/null +++ b/classes/lynch_incident_episode_type.py @@ -0,0 +1,47 @@ +class LynchIncidentEpisodeType: + """ + Utility class for mapping symbolic values used to filter Lynch incident episode linkage. + + This class provides: + - Symbolic constants for filtering Lynch incident episodes, such as "null", "not_null", "latest_episode", and "earlier_episode". + - A method to convert a description to a valid symbolic constant. + + Members: + NULL: Represents a null value. + NOT_NULL: Represents a not-null value. + LATEST_EPISODE: Represents the latest episode. + EARLIER_EPISODE: Represents an earlier episode. + + Methods: + from_description(description: str) -> str: + Returns the symbolic constant for a given description. + Raises ValueError if the description is not recognized. + """ + + NULL = "null" + NOT_NULL = "not_null" + LATEST_EPISODE = "latest_episode" + EARLIER_EPISODE = "earlier_episode" + + _symbolics = {NULL, NOT_NULL, LATEST_EPISODE, EARLIER_EPISODE} + + @classmethod + def from_description(cls, description: str) -> str: + """ + Returns the symbolic constant for a given description. + + Args: + description (str): The description to check. + + Returns: + str: The symbolic constant matching the description. + + Raises: + ValueError: If the description is not recognized. + """ + key = description.strip().lower() + if key not in cls._symbolics: + raise ValueError( + f"Unknown Lynch incident episode criteria: '{description}'" + ) + return key diff --git a/classes/lynch_sdd_reason_for_change_type.py b/classes/lynch_sdd_reason_for_change_type.py new file mode 100644 index 00000000..bc6e9f91 --- /dev/null +++ b/classes/lynch_sdd_reason_for_change_type.py @@ -0,0 +1,115 @@ +from enum import Enum +from typing import Optional + + +class LynchSDDReasonForChangeType(Enum): + """ + Enum representing reasons for change to Lynch SDD (Surveillance Due Date). + + Methods: + valid_value_id: Returns the unique identifier for the reason. + description: Returns the string description of the reason. + by_valid_value_id(valid_value_id: int) -> Optional[LynchSDDReasonForChangeType]: Returns the enum member matching the given valid value ID. + by_description(description: str) -> Optional[LynchSDDReasonForChangeType]: Returns the enum member matching the given description. + by_description_case_insensitive(description: str) -> Optional[LynchSDDReasonForChangeType]: Returns the enum member matching the given description (case-insensitive). + """ + + CEASED = (305690, "Ceased") + DATE_OF_BIRTH_AMENDMENT = (306456, "Date of Birth amendment") + DISCHARGED_PATIENT_UNFIT = (305693, "Discharged, Patient Unfit") + LYNCH_SURVEILLANCE = (305684, "Lynch Surveillance") + SELF_REFERRAL = (307130, "Self-referral") + OPT_IN = (305718, "Opt-in") + OPT_BACK_INTO_SCREENING_PROGRAMME = (305711, "Opt (Back) into Screening Programme") + OPT_IN_DUE_TO_ERROR = (305712, "Opt-in due to Error") + REOPENED_EPISODE = (305706, "Reopened Episode") + RESULT_REFERRED_FOR_CANCER_TREATMENT = ( + 305692, + "Result referred for Cancer Treatment", + ) + REVERSAL_OF_DEATH_NOTIFICATION = (305713, "Reversal of Death Notification") + SELECTED_FOR_LYNCH_SURVEILLANCE = (307071, "Selected for Lynch Surveillance") + NULL = (None, "null") + NOT_NULL = (None, "not null") + UNCHANGED = (None, "unchanged") + + def __init__(self, valid_value_id: Optional[int], description: str): + """ + Initialize a LynchSDDReasonForChangeType enum member. + + Args: + valid_value_id (Optional[int]): The unique identifier for the reason. + description (str): The string description of the reason. + """ + self._valid_value_id = valid_value_id + self._description = description + + @property + def valid_value_id(self) -> Optional[int]: + """ + Returns the unique identifier for the reason. + + Returns: + Optional[int]: The valid value ID. + """ + return self._valid_value_id + + @property + def description(self) -> str: + """ + Returns the string description of the reason. + + Returns: + str: The description. + """ + return self._description + + @classmethod + def by_valid_value_id( + cls, valid_value_id: int + ) -> Optional["LynchSDDReasonForChangeType"]: + """ + Returns the enum member matching the given valid value ID. + + Args: + valid_value_id (int): The valid value ID to search for. + + Returns: + Optional[LynchSDDReasonForChangeType]: The matching enum member, or None if not found. + """ + return next( + (item for item in cls if item.valid_value_id == valid_value_id), None + ) + + @classmethod + def by_description( + cls, description: str + ) -> Optional["LynchSDDReasonForChangeType"]: + """ + Returns the enum member matching the given description. + + Args: + description (str): The description to search for. + + Returns: + Optional[LynchSDDReasonForChangeType]: The matching enum member, or None if not found. + """ + return next((item for item in cls if item.description == description), None) + + @classmethod + def by_description_case_insensitive( + cls, description: str + ) -> Optional["LynchSDDReasonForChangeType"]: + """ + Returns the enum member matching the given description (case-insensitive). + + Args: + description (str): The description to search for. + + Returns: + Optional[LynchSDDReasonForChangeType]: The matching enum member, or None if not found. + """ + return next( + (item for item in cls if item.description.lower() == description.lower()), + None, + ) diff --git a/classes/manual_cease_requested.py b/classes/manual_cease_requested.py new file mode 100644 index 00000000..7ffdda8a --- /dev/null +++ b/classes/manual_cease_requested.py @@ -0,0 +1,81 @@ +from enum import Enum +from typing import Optional + + +class ManualCeaseRequested(Enum): + """ + Enum representing the manual cease request status for a subject. + + Members: + NO: No manual cease requested. + DISCLAIMER_LETTER_REQUIRED: Disclaimer letter required (C1). + DISCLAIMER_LETTER_SENT: Disclaimer letter sent (C2). + YES: Yes, manual cease requested. + + Methods: + description: Returns the string description of the enum member. + by_description(description: str) -> Optional[ManualCeaseRequested]: + Returns the enum member matching the given description, or None if not found. + by_description_case_insensitive(description: str) -> Optional[ManualCeaseRequested]: + Returns the enum member matching the given description (case-insensitive), or None if not found. + """ + + NO = "no" + DISCLAIMER_LETTER_REQUIRED = "yes - disclaimer letter required (c1)" + DISCLAIMER_LETTER_SENT = "yes - disclaimer letter sent (c2)" + YES = "yes" + + def __init__(self, description: str) -> None: + """ + Initialize a ManualCeaseRequested enum member. + + Args: + description (str): The string description of the manual cease request status. + """ + self._description: str = description + + @property + def description(self) -> str: + """ + Returns the string description of the enum member. + + Returns: + str: The description value. + """ + return self._description + + @classmethod + def by_description(cls, description: str) -> Optional["ManualCeaseRequested"]: + """ + Returns the enum member matching the given description. + + Args: + description (str): The description to search for. + + Returns: + Optional[ManualCeaseRequested]: The matching enum member, or None if not found. + """ + for item in cls: + if item.description == description: + return item + return None + + @classmethod + def by_description_case_insensitive( + cls, description: str + ) -> Optional["ManualCeaseRequested"]: + """ + Returns the enum member matching the given description (case-insensitive). + + Args: + description (str): The description to search for. + + Returns: + Optional[ManualCeaseRequested]: The matching enum member, or None if not found. + """ + if description is None: + return None + for item in cls: + if item.description.lower() == description.lower(): + return item + return None diff --git a/classes/notify_event_status.py b/classes/notify_event_status.py new file mode 100644 index 00000000..5e484c03 --- /dev/null +++ b/classes/notify_event_status.py @@ -0,0 +1,39 @@ +class NotifyEventStatus: + """ + Utility class for mapping Notify event status descriptions to internal IDs. + + This class provides: + - A mapping from Notify event status codes (e.g., "S1", "S2", "M1") to their corresponding internal IDs. + - A method to retrieve the internal ID for a given Notify event status description. + + Methods: + get_id(description: str) -> int: + Returns the internal ID for a given Notify event status description. + Raises ValueError if the description is not recognized. + """ + + _label_to_id = { + "S1": 9901, + "S2": 9902, + "M1": 9903, + # Extend as needed + } + + @classmethod + def get_id(cls, description: str) -> int: + """ + Returns the internal ID for a given Notify event status description. + + Args: + description (str): The Notify event status code (e.g., "S1"). + + Returns: + int: The internal ID corresponding to the description. + + Raises: + ValueError: If the description is not recognized. + """ + key = description.strip().upper() + if key not in cls._label_to_id: + raise ValueError(f"Unknown Notify event type: '{description}'") + return cls._label_to_id[key] diff --git a/classes/organisation.py b/classes/organisation.py new file mode 100644 index 00000000..0f79f583 --- /dev/null +++ b/classes/organisation.py @@ -0,0 +1,26 @@ +class Organisation: + """ + Class representing an organisation with a unique organisation ID. + + Methods: + get_organisation_id() -> str: + Returns the organisation's unique ID. + """ + + def __init__(self, organisation_id: str): + """ + Initialize an Organisation instance. + + Args: + organisation_id (str): The unique identifier for the organisation. + """ + self.organisation_id = organisation_id + + def get_organisation_id(self) -> str: + """ + Returns the organisation's unique ID. + + Returns: + str: The organisation ID. + """ + return self.organisation_id diff --git a/classes/prevalent_incident_status_type.py b/classes/prevalent_incident_status_type.py new file mode 100644 index 00000000..825f8b0e --- /dev/null +++ b/classes/prevalent_incident_status_type.py @@ -0,0 +1,37 @@ +class PrevalentIncidentStatusType: + """ + Utility class for mapping symbolic values for FOBT prevalent/incident episode classification. + + Members: + PREVALENT: Represents a prevalent episode. + INCIDENT: Represents an incident episode. + + Methods: + from_description(description: str) -> str: + Returns the symbolic value ("prevalent" or "incident") for a given description. + Raises ValueError if the description is not recognized. + """ + + PREVALENT = "prevalent" + INCIDENT = "incident" + + _valid_values = {PREVALENT, INCIDENT} + + @classmethod + def from_description(cls, description: str) -> str: + """ + Returns the symbolic value ("prevalent" or "incident") for a given description. + + Args: + description (str): The description to check. + + Returns: + str: The symbolic value ("prevalent" or "incident"). + + Raises: + ValueError: If the description is not recognized. + """ + key = description.strip().lower() + if key not in cls._valid_values: + raise ValueError(f"Unknown FOBT episode status: '{description}'") + return key diff --git a/classes/screening_referral_type.py b/classes/screening_referral_type.py new file mode 100644 index 00000000..b2002328 --- /dev/null +++ b/classes/screening_referral_type.py @@ -0,0 +1,39 @@ +class ScreeningReferralType: + """ + Utility class for mapping screening referral descriptions to valid value IDs. + + This class provides: + - A mapping from human-readable screening referral descriptions (e.g., "gp", "self referral", "hospital") to their corresponding internal valid value IDs. + - A method to retrieve the valid value ID for a given description. + + Methods: + get_id(description: str) -> int: + Returns the valid value ID for a given screening referral description. + Raises ValueError if the description is not recognized. + """ + + _label_to_id = { + "gp": 9701, + "self referral": 9702, + "hospital": 9703, + # Add more as needed + } + + @classmethod + def get_id(cls, description: str) -> int: + """ + Returns the valid value ID for a given screening referral description. + + Args: + description (str): The screening referral description (e.g., "gp"). + + Returns: + int: The valid value ID corresponding to the description. + + Raises: + ValueError: If the description is not recognized. + """ + key = description.strip().lower() + if key not in cls._label_to_id: + raise ValueError(f"Unknown screening referral type: '{description}'") + return cls._label_to_id[key] diff --git a/classes/screening_status_type.py b/classes/screening_status_type.py new file mode 100644 index 00000000..ab88cffa --- /dev/null +++ b/classes/screening_status_type.py @@ -0,0 +1,126 @@ +from enum import Enum +from typing import Optional + + +class ScreeningStatusType(Enum): + """ + Enum representing different screening status types for a subject. + + Members: + CALL: Call status (valid_value_id=4001) + INACTIVE: Inactive status (valid_value_id=4002) + OPT_IN: Opt-in status (valid_value_id=4003) + RECALL: Recall status (valid_value_id=4004) + SELF_REFERRAL: Self-referral status (valid_value_id=4005) + SURVEILLANCE: Surveillance status (valid_value_id=4006) + SEEKING_FURTHER_DATA: Seeking Further Data status (valid_value_id=4007) + CEASED: Ceased status (valid_value_id=4008) + BOWEL_SCOPE: Bowel Scope status (valid_value_id=4009) + LYNCH: Lynch Surveillance status (valid_value_id=306442) + LYNCH_SELF_REFERRAL: Lynch Self-referral status (valid_value_id=307129) + NULL: Null value for subject selection criteria (valid_value_id=0) + NOT_NULL: Not Null value for subject selection criteria (valid_value_id=0) + + Methods: + valid_value_id: Returns the unique identifier for the screening status. + description: Returns the string description of the screening status. + by_description(description: str) -> Optional[ScreeningStatusType]: Returns the enum member matching the given description. + by_description_case_insensitive(description: str) -> Optional[ScreeningStatusType]: Returns the enum member matching the given description (case-insensitive). + by_valid_value_id(valid_value_id: int) -> Optional[ScreeningStatusType]: Returns the enum member matching the given valid value ID. + """ + + CALL = (4001, "Call") + INACTIVE = (4002, "Inactive") + OPT_IN = (4003, "Opt-in") + RECALL = (4004, "Recall") + SELF_REFERRAL = (4005, "Self-referral") + SURVEILLANCE = (4006, "Surveillance") + SEEKING_FURTHER_DATA = (4007, "Seeking Further Data") + CEASED = (4008, "Ceased") + BOWEL_SCOPE = (4009, "Bowel Scope") + LYNCH = (306442, "Lynch Surveillance") + LYNCH_SELF_REFERRAL = (307129, "Lynch Self-referral") + NULL = (0, "Null") + NOT_NULL = (0, "Not null") + + def __init__(self, valid_value_id: int, description: str): + """ + Initialize a ScreeningStatusType enum member. + + Args: + valid_value_id (int): The unique identifier for the screening status. + description (str): The string description of the screening status. + """ + self._value_id = valid_value_id + self._description = description + + @property + def valid_value_id(self) -> int: + """ + Returns the unique identifier for the screening status. + + Returns: + int: The valid value ID. + """ + return self._value_id + + @property + def description(self) -> str: + """ + Returns the string description of the screening status. + + Returns: + str: The description. + """ + return self._description + + @classmethod + def by_description(cls, description: str) -> Optional["ScreeningStatusType"]: + """ + Returns the enum member matching the given description. + + Args: + description (str): The description to search for. + + Returns: + Optional[ScreeningStatusType]: The matching enum member, or None if not found. + """ + for item in cls: + if item.description == description: + return item + return None + + @classmethod + def by_description_case_insensitive( + cls, description: str + ) -> Optional["ScreeningStatusType"]: + """ + Returns the enum member matching the given description (case-insensitive). + + Args: + description (str): The description to search for. + + Returns: + Optional[ScreeningStatusType]: The matching enum member, or None if not found. + """ + description = description.lower() + for item in cls: + if item.description.lower() == description: + return item + return None + + @classmethod + def by_valid_value_id(cls, valid_value_id: int) -> Optional["ScreeningStatusType"]: + """ + Returns the enum member matching the given valid value ID. + + Args: + valid_value_id (int): The valid value ID to search for. + + Returns: + Optional[ScreeningStatusType]: The matching enum member, or None if not found. + """ + for item in cls: + if item.valid_value_id == valid_value_id: + return item + return None diff --git a/classes/sdd_reason_for_change_type.py b/classes/sdd_reason_for_change_type.py new file mode 100644 index 00000000..50b6524d --- /dev/null +++ b/classes/sdd_reason_for_change_type.py @@ -0,0 +1,212 @@ +from enum import Enum +from typing import Optional + + +class SDDReasonForChangeType(Enum): + """ + Enum representing reasons for change to SDD (Surveillance Due Date). + + Members: + AGE_EXTENSION_FIRST_CALL: Age Extension First Call + AGE_EXTENSION_RETURN_TO_RECALL: Age Extension Return to Recall + AWAITING_FAILSAFE: Awaiting Failsafe + CEASED: Ceased + DATE_OF_BIRTH_AMENDMENT: Date of Birth amendment + DISCHARGE_FROM_SCREENING_AGE: Discharge From Screening - Age + DISCHARGE_FROM_SURVEILLANCE_AGE: Discharge from Surveillance - Age + DISCHARGE_FROM_SURVEILLANCE_CANNOT_CONTACT_PATIENT: Discharge from Surveillance - Cannot Contact Patient + DISCHARGE_FROM_SURVEILLANCE_CLINICAL_DECISION: Discharge from Surveillance - Clinical Decision + DISCHARGE_FROM_SURVEILLANCE_NATIONAL_GUIDELINES: Discharge from Surveillance - National Guidelines + DISCHARGE_FROM_SURVEILLANCE_PATIENT_CHOICE: Discharge from Surveillance - Patient Choice + DISCHARGE_SURVEILLANCE_REVIEW_2019_GUIDELINES: Discharge, Surveillance Review, 2019 Guidelines + DISCHARGED_PATIENT_UNFIT: Discharged, Patient Unfit + ELIGIBLE_FOR_SCREENING: Eligible for Screening + ELIGIBLE_TO_BE_INVITED_FOR_FS_SCREENING: Eligible to be invited for FS Screening + FAILSAFE_TRAWL: Failsafe Trawl + IMPLEMENTATION_RESET: Implementation Reset + LATE_RESPONSE: Late Response + MANUAL_CALL_RECALL_AMENDMENT: Manual Call/Recall Amendment + MANUALLY_REFERRED_TO_SURVEILLANCE_2019_GUIDELINES: Manually Referred to Surveillance, 2019 Guidelines + MOVE_OUT_OF_IMPLEMENTATION: Move out of Implementation + MOVED_TO_LYNCH_SURVEILLANCE: Moved to Lynch surveillance + MULTIPLE_DATE_OF_BIRTH_CHANGES: Multiple Date of Birth Changes + NO_LONGER_ELIGIBLE_TO_BE_INVITED_FOR_FS_SCREENING: No longer eligible to be invited for FS Screening + OPT_BACK_INTO_SCREENING_PROGRAMME: Opt (Back) into Screening Programme + OPTIN_DUE_TO_ERROR: Opt-in due to Error + POSTPONE_SURVEILLANCE_REVIEW_2019_GUIDELINES: Postpone, Surveillance Review, 2019 Guidelines + RECALL: Recall + REOPENED_EPISODE: Reopened Episode + REQUEST_SCREENING_EPISODE_OUTSIDE_NORMAL_RECALL: Request screening episode outside normal recall + RESET_AFTER_BEING_PAUSED: Reset after being Paused + RESET_AFTER_BEING_PAUSED_AND_CEASED: Reset after being Paused and Ceased + RESULT_REFERRED_FOR_CANCER_TREATMENT: Result Referred for Cancer treatment + RESULT_REFERRED_TO_SURVEILLANCE: Result referred to Surveillance + REVERSAL_OF_DEATH_NOTIFICATION: Reversal of Death Notification + ROLLOUT_IMPLEMENTATION: Rollout Implementation + SELFREFERRAL: Self-Referral + NULL: Null value for subject selection criteria + NOT_NULL: Not Null value for subject selection criteria + UNCHANGED: Unchanged value for subject selection criteria + + Methods: + valid_value_id: Returns the unique identifier for the reason. + description: Returns the string description of the reason. + by_valid_value_id(valid_value_id: int) -> Optional[SDDReasonForChangeType]: Returns the enum member matching the given valid value ID. + by_description(description: str) -> Optional[SDDReasonForChangeType]: Returns the enum member matching the given description. + by_description_case_insensitive(description: str) -> Optional[SDDReasonForChangeType]: Returns the enum member matching the given description (case-insensitive). + """ + + AGE_EXTENSION_FIRST_CALL = (20417, "Age Extension First Call") + AGE_EXTENSION_RETURN_TO_RECALL = (20416, "Age Extension Return to Recall") + AWAITING_FAILSAFE = (307057, "Awaiting Failsafe") + CEASED = (11329, "Ceased") + DATE_OF_BIRTH_AMENDMENT = (11567, "Date of Birth amendment") + DISCHARGE_FROM_SCREENING_AGE = (20233, "Discharge From Screening - Age") + DISCHARGE_FROM_SURVEILLANCE_AGE = (20044, "Discharge from Surveillance - Age") + DISCHARGE_FROM_SURVEILLANCE_CANNOT_CONTACT_PATIENT = ( + 20047, + "Discharge from Surveillance - Cannot Contact Patient", + ) + DISCHARGE_FROM_SURVEILLANCE_CLINICAL_DECISION = ( + 20048, + "Discharge from Surveillance - Clinical Decision", + ) + DISCHARGE_FROM_SURVEILLANCE_NATIONAL_GUIDELINES = ( + 20045, + "Discharge from Surveillance - National Guidelines", + ) + DISCHARGE_FROM_SURVEILLANCE_PATIENT_CHOICE = ( + 20046, + "Discharge from Surveillance - Patient Choice", + ) + DISCHARGE_SURVEILLANCE_REVIEW_2019_GUIDELINES = ( + 305547, + "Discharge, Surveillance Review, 2019 Guidelines", + ) + DISCHARGED_PATIENT_UNFIT = (20438, "Discharged, Patient Unfit") + ELIGIBLE_FOR_SCREENING = (20425, "Eligible for Screening") + ELIGIBLE_TO_BE_INVITED_FOR_FS_SCREENING = ( + 203184, + "Eligible to be invited for FS Screening", + ) + FAILSAFE_TRAWL = (11525, "Failsafe Trawl") + IMPLEMENTATION_RESET = (11331, "Implementation Reset") + LATE_RESPONSE = (200511, "Late Response") + MANUAL_CALL_RECALL_AMENDMENT = (11330, "Manual Call/Recall Amendment") + MANUALLY_REFERRED_TO_SURVEILLANCE_2019_GUIDELINES = ( + 305566, + "Manually Referred to Surveillance, 2019 Guidelines", + ) + MOVE_OUT_OF_IMPLEMENTATION = (11536, "Move out of Implementation") + MOVED_TO_LYNCH_SURVEILLANCE = (306450, "Moved to Lynch surveillance") + MULTIPLE_DATE_OF_BIRTH_CHANGES = (202446, "Multiple Date of Birth Changes") + NO_LONGER_ELIGIBLE_TO_BE_INVITED_FOR_FS_SCREENING = ( + 200671, + "No longer eligible to be invited for FS Screening", + ) + OPT_BACK_INTO_SCREENING_PROGRAMME = (11333, "Opt (Back) into Screening Programme") + OPTIN_DUE_TO_ERROR = (11531, "Opt-in due to Error") + POSTPONE_SURVEILLANCE_REVIEW_2019_GUIDELINES = ( + 305548, + "Postpone, Surveillance Review, 2019 Guidelines", + ) + RECALL = (11334, "Recall") + REOPENED_EPISODE = (20297, "Reopened Episode") + REQUEST_SCREENING_EPISODE_OUTSIDE_NORMAL_RECALL = ( + 200513, + "Request screening episode outside normal recall", + ) + RESET_AFTER_BEING_PAUSED = (20452, "Reset after being Paused") + RESET_AFTER_BEING_PAUSED_AND_CEASED = (20453, "Reset after being Paused and Ceased") + RESULT_REFERRED_FOR_CANCER_TREATMENT = ( + 11336, + "Result Referred for Cancer treatment", + ) + RESULT_REFERRED_TO_SURVEILLANCE = (11335, "Result referred to Surveillance") + REVERSAL_OF_DEATH_NOTIFICATION = (11565, "Reversal of Death Notification") + ROLLOUT_IMPLEMENTATION = (11337, "Rollout Implementation") + SELFREFERRAL = (11332, "Self-Referral") + + # Special values (no valid_value_id) + NULL = (None, "null") + NOT_NULL = (None, "not null") + UNCHANGED = (None, "unchanged") + + def __init__(self, valid_value_id: Optional[int], description: str): + """ + Initialize an SDDReasonForChangeType enum member. + + Args: + valid_value_id (Optional[int]): The unique identifier for the reason. + description (str): The string description of the reason. + """ + self._valid_value_id = valid_value_id + self._description = description + + @property + def valid_value_id(self) -> Optional[int]: + """ + Returns the unique identifier for the reason. + + Returns: + Optional[int]: The valid value ID. + """ + return self._valid_value_id + + @property + def description(self) -> str: + """ + Returns the string description of the reason. + + Returns: + str: The description. + """ + return self._description + + @classmethod + def by_valid_value_id( + cls, valid_value_id: int + ) -> Optional["SDDReasonForChangeType"]: + """ + Returns the enum member matching the given valid value ID. + + Args: + valid_value_id (int): The valid value ID to search for. + + Returns: + Optional[SDDReasonForChangeType]: The matching enum member, or None if not found. + """ + return next( + (item for item in cls if item.valid_value_id == valid_value_id), None + ) + + @classmethod + def by_description(cls, description: str) -> Optional["SDDReasonForChangeType"]: + """ + Returns the enum member matching the given description. + + Args: + description (str): The description to search for. + + Returns: + Optional[SDDReasonForChangeType]: The matching enum member, or None if not found. + """ + return next((item for item in cls if item.description == description), None) + + @classmethod + def by_description_case_insensitive( + cls, description: str + ) -> Optional["SDDReasonForChangeType"]: + """ + Returns the enum member matching the given description (case-insensitive). + + Args: + description (str): The description to search for. + + Returns: + Optional[SDDReasonForChangeType]: The matching enum member, or None if not found. + """ + return next( + (item for item in cls if item.description.lower() == description.lower()), + None, + ) diff --git a/classes/selection_builder_exception.py b/classes/selection_builder_exception.py new file mode 100644 index 00000000..bf3102ea --- /dev/null +++ b/classes/selection_builder_exception.py @@ -0,0 +1,24 @@ +from typing import Optional + + +class SelectionBuilderException(Exception): + """ + Exception used for subject selection errors in the selection builder. + + This exception is raised when an invalid or unexpected value is encountered + during subject selection logic. + """ + + def __init__(self, message_or_key: str, value: Optional[str] = None): + """ + Initialize a SelectionBuilderException. + + Args: + message_or_key (str): The error message or the key for which the error occurred. + value (Optional[str]): The invalid value, if applicable. + """ + if value is None: + message = message_or_key + else: + message = f"Invalid '{message_or_key}' value: '{value}'" + super().__init__(message) diff --git a/classes/ss_reason_for_change_type.py b/classes/ss_reason_for_change_type.py new file mode 100644 index 00000000..0d162e3d --- /dev/null +++ b/classes/ss_reason_for_change_type.py @@ -0,0 +1,182 @@ +from enum import Enum +from typing import Optional + + +class SSReasonForChangeType(Enum): + """ + Enum representing reasons for change to SS (Screening Status). + + Methods: + valid_value_id: Returns the unique identifier for the reason. + description: Returns the string description of the reason. + by_valid_value_id(valid_value_id: int) -> Optional[SSReasonForChangeType]: Returns the enum member matching the given valid value ID. + by_description(description: str) -> Optional[SSReasonForChangeType]: Returns the enum member matching the given description. + by_description_case_insensitive(description: str) -> Optional[SSReasonForChangeType]: Returns the enum member matching the given description (case-insensitive). + """ + + AGE_EXTENSION_FIRST_CALL = (20415, "Age Extension First Call") + AGE_EXTENSION_FIRST_CALL_MAY_SELF_REFER = ( + 20413, + "Age Extension First Call - May Self-refer", + ) + AGE_EXTENSION_UN_CEASE = (20414, "Age Extension Uncease") + AGE_EXTENSION_UN_CEASE_MAY_SELF_REFER = ( + 20412, + "Age Extension Uncease - May Self-refer", + ) + CANCELLED_REGISTRATION = (11313, "Cancelled Registration") + CLINICAL_REASON = (11311, "Clinical Reason") + DATE_OF_BIRTH_AMENDMENT = (205267, "Date of birth amendment") + DECEASED = (11309, "Deceased") + DISCHARGE_CANNOT_CONTACT = ( + 20006, + "Discharge from Surveillance - Cannot Contact Patient", + ) + DISCHARGE_CLINICAL_DECISION = ( + 20007, + "Discharge from Surveillance - Clinical Decision", + ) + DISCHARGE_NATIONAL_GUIDELINES = ( + 20004, + "Discharge from Surveillance - National Guidelines", + ) + DISCHARGE_PATIENT_CHOICE = (20005, "Discharge from Surveillance - Patient Choice") + DISCHARGED_PATIENT_UNFIT = (20437, "Discharged, Patient Unfit") + ELIGIBLE_FOR_SCREENING = (20424, "Eligible for Screening") + ELIGIBLE_FS = (200641, "Eligible to be invited for FS Screening") + FAILSAFE_TRAWL = (20426, "Failsafe Trawl") + IMPLEMENTATION_RESET = (11323, "Implementation Reset") + LEFT_COUNTRY = (11310, "Individual has left the country") + INFORMAL_DEATH = (47, "Informal Death") + INFORMED_DISSENT = (43, "Informed Dissent") + INFORMED_DISSENT_HISTORIC = (11308, "Informed Dissent (historic)") + INFORMED_DISSENT_VERBAL = (44, "Informed Dissent (verbal only)") + INFORMED_DISSENT_VERBAL_HISTORIC = ( + 11534, + "Informed Dissent (verbal only) (historic)", + ) + LATE_RESPONSE = (200510, "Late Response") + LOST_PATIENT_CONTACT = (20156, "Lost Patient Contact") + MANUAL_FAILSAFE = (11324, "Manual Failsafe") + MOVE_OUT_OF_IMPLEMENTATION = (11535, "Move out of Implementation") + MULTIPLE_DOB_CHANGES = (202447, "Multiple Date of Birth Changes") + NO_COLON_PROGRAMME_ASSESSED = (46, "No Colon (programme assessed)") + NO_COLON_SUBJECT_REQUEST = (45, "No Colon (subject request)") + NOT_ELIGIBLE_FS = (200642, "No longer eligible to be invited for FS Screening") + OPT_IN = (305717, "Opt-in") + OPT_BACK = (11317, "Opt (Back) into Screening Programme") + OPT_IN_ERROR = (11530, "Opt-in due to Error") + OUTSIDE_POPULATION = (11312, "Outside Screening Population") + PATIENT_CHOICE = (20157, "Patient Choice") + RECALL = (11404, "Recall") + REINSTATE_SURVEILLANCE = (20264, "Reinstate Surveillance") + REINSTATE_DUE_TO_ERROR = (20263, "Reinstate Surveillance due to Error") + REINSTATE_REVERSAL_DEATH = ( + 11564, + "Reinstate Surveillance for Reversal of Death Notification", + ) + REOPENED_EPISODE = (11327, "Reopened Episode") + REOPENED_FS_EPISODE = (205011, "Reopened FS Episode") + IMMEDIATE_SCREENING = (65, "Request for Immediate Screening Episode") + RESET_PAUSED_CEASED = (20451, "Reset after being Paused and Ceased") + RESET_TO_CALL = (11320, "Reset seeking further data to Call") + RESET_TO_FS = (205007, "Reset seeking further data to FS Screening") + RESET_TO_INACTIVE = (11321, "Reset seeking further data to Inactive") + RESET_TO_LYNCH = (307079, "Reset seeking further data to Lynch Surveillance") + RESET_TO_LYNCH_SR = (307135, "Reset seeking further data to Lynch Self-referral") + RESET_TO_OPTIN = (11528, "Reset seeking further data to Opt-In") + RESET_TO_RECALL = (11322, "Reset seeking further data to Recall") + RESET_TO_SELF_REFERRAL = (11529, "Reset seeking further data to Self-referral") + RESET_TO_SURVEILLANCE = (20299, "Reset seeking further data to Surveillance") + RESET_TO_BOWEL_SCOPE = (203183, "Reset seeking further data to bowel scope") + RESULT_CANCER_TREATMENT = (11326, "Result referred for Cancer Treatment") + RESULT_TO_SURVEILLANCE = (11325, "Result referred to Surveillance") + REVERSAL_DEATH = (11563, "Reversal of Death Notification") + ROLLOUT_IMPLEMENTATION = (11328, "Rollout Implementation") + SELECTED_FOR_SURVEILLANCE = (20003, "Selected for Surveillance") + SELF_REFERRAL = (11316, "Self-Referral") + SET_TO_CALL_RECALL = (203055, "Set Seeking Further Data to Call/Recall") + UNCERTIFIED_DEATH = (11314, "Uncertified Death") + UNCONFIRMED_CLINICAL_HISTORIC = (11315, "Unconfirmed Clinical Reason (historic)") + ELIGIBLE_FOR_LYNCH = (306447, "Eligible for Lynch surveillance") + LYNCH_SURVEILLANCE = (305689, "Lynch Surveillance") + BOWEL_SCOPE_DECOMMISSIONED = (307051, "Bowel scope decommissioned") + + def __init__(self, valid_value_id: int, description: str): + """ + Initialize an SSReasonForChangeType enum member. + + Args: + valid_value_id (int): The unique identifier for the reason. + description (str): The string description of the reason. + """ + self._value_id = valid_value_id + self._description = description + + @property + def valid_value_id(self) -> int: + """ + Returns the unique identifier for the reason. + + Returns: + int: The valid value ID. + """ + return self._value_id + + @property + def description(self) -> str: + """ + Returns the string description of the reason. + + Returns: + str: The description. + """ + return self._description + + @classmethod + def by_valid_value_id( + cls, valid_value_id: int + ) -> Optional["SSReasonForChangeType"]: + """ + Returns the enum member matching the given valid value ID. + + Args: + valid_value_id (int): The valid value ID to search for. + + Returns: + Optional[SSReasonForChangeType]: The matching enum member, or None if not found. + """ + return next( + (item for item in cls if item.valid_value_id == valid_value_id), None + ) + + @classmethod + def by_description(cls, description: str) -> Optional["SSReasonForChangeType"]: + """ + Returns the enum member matching the given description. + + Args: + description (str): The description to search for. + + Returns: + Optional[SSReasonForChangeType]: The matching enum member, or None if not found. + """ + return next((item for item in cls if item.description == description), None) + + @classmethod + def by_description_case_insensitive( + cls, description: str + ) -> Optional["SSReasonForChangeType"]: + """ + Returns the enum member matching the given description (case-insensitive). + + Args: + description (str): The description to search for. + + Returns: + Optional[SSReasonForChangeType]: The matching enum member, or None if not found. + """ + return next( + (item for item in cls if item.description.lower() == description.lower()), + None, + ) diff --git a/classes/ssdd_reason_for_change_type.py b/classes/ssdd_reason_for_change_type.py new file mode 100644 index 00000000..6eaf0f1d --- /dev/null +++ b/classes/ssdd_reason_for_change_type.py @@ -0,0 +1,154 @@ +from enum import Enum +from typing import Optional + + +class SSDDReasonForChangeType(Enum): + """ + Enum representing reasons for change to SSDD (Surveillance Status Due Date). + + Methods: + valid_value_id: Returns the unique identifier for the reason. + description: Returns the string description of the reason. + by_valid_value_id(valid_value_id: int) -> Optional[SSDDReasonForChangeType]: Returns the enum member matching the given valid value ID. + by_description(description: str) -> Optional[SSDDReasonForChangeType]: Returns the enum member matching the given description. + by_description_case_insensitive(description: str) -> Optional[SSDDReasonForChangeType]: Returns the enum member matching the given description (case-insensitive). + """ + + CEASED = (67, "Ceased") + CONTINUE_WITH_SURVEILLANCE = (20285, "Continue with Surveillance") + DIRECTION_OF_CONSULTANT = (11468, "Direction of Consultant") + DIRECTION_OF_SCREENING_PRACTITIONER = ( + 200278, + "Direction of Screening Practitioner", + ) + DISCHARGE_FROM_SURVEILLANCE_AGE = (20039, "Discharge from Surveillance - Age") + DISCHARGE_FROM_SURVEILLANCE_CANNOT_CONTACT_PATIENT = ( + 20042, + "Discharge from Surveillance - Cannot Contact Patient", + ) + DISCHARGE_FROM_SURVEILLANCE_CLINICAL_DECISION = ( + 20043, + "Discharge from Surveillance - Clinical Decision", + ) + DISCHARGE_FROM_SURVEILLANCE_NATIONAL_GUIDELINES = ( + 20040, + "Discharge from Surveillance - National Guidelines", + ) + DISCHARGE_FROM_SURVEILLANCE_PATIENT_CHOICE = ( + 20041, + "Discharge from Surveillance - Patient Choice", + ) + DISCHARGE_SURVEILLANCE_REVIEW_2019_GUIDELINES = ( + 305549, + "Discharge, Surveillance Review, 2019 Guidelines", + ) + DISCHARGED_PATIENT_UNFIT = (20439, "Discharged, Patient Unfit") + FIT_RESEARCH_PROJECT = (200280, "FIT Research Project") + MANUAL_AMENDMENT = (20236, "Manual Amendment") + MANUALLY_REFERRED_TO_SURVEILLANCE_2019_GUIDELINES = ( + 305568, + "Manually Referred to Surveillance, 2019 Guidelines", + ) + MOVED_TO_LYNCH_SURVEILLANCE = (306451, "Moved to Lynch surveillance") + PATIENT_REQUEST = (11469, "Patient Request") + POSTPONE_SURVEILLANCE_REVIEW_2019_GUIDELINES = ( + 305550, + "Postpone, Surveillance Review, 2019 Guidelines", + ) + REFERRED_FOR_CANCER_TREATMENT = (20304, "Referred for Cancer treatment") + REINSTATE_SURVEILLANCE = (20266, "Reinstate Surveillance") + REINSTATE_SURVEILLANCE_DUE_TO_ERROR = (20265, "Reinstate Surveillance due to Error") + REINSTATE_SURVEILLANCE_FOR_REVERSAL_OF_DEATH_NOTIFICATION = ( + 11566, + "Reinstate Surveillance for Reversal of Death Notification", + ) + RELATIVE_CARER_REQUEST = (200277, "Relative/Carer Request") + REOPENED_EPISODE = (20298, "Reopened Episode") + RESULT_HIGH_RISK_ADENOMA = (11349, "Result - High Risk Adenoma") + RESULT_INTERMEDIATE_RISK_ADENOMA = (11348, "Result - Intermediate Risk Adenoma") + RETURNED_UNDELIVERED_MAIL = (200279, "Returned/Undelivered Mail") + LNPCP = (305614, "Result - LNPCP") + HIGH_RISK_FINDINGS = (305615, "Result - High-risk findings") + + NULL = (None, "null") + NOT_NULL = (None, "not null") + UNCHANGED = (None, "unchanged") + + def __init__(self, valid_value_id: Optional[int], description: str): + """ + Initialize an SSDDReasonForChangeType enum member. + + Args: + valid_value_id (Optional[int]): The unique identifier for the reason. + description (str): The string description of the reason. + """ + self._valid_value_id = valid_value_id + self._description = description + + @property + def valid_value_id(self) -> Optional[int]: + """ + Returns the unique identifier for the reason. + + Returns: + Optional[int]: The valid value ID. + """ + return self._valid_value_id + + @property + def description(self) -> str: + """ + Returns the string description of the reason. + + Returns: + str: The description. + """ + return self._description + + @classmethod + def by_valid_value_id( + cls, valid_value_id: int + ) -> Optional["SSDDReasonForChangeType"]: + """ + Returns the enum member matching the given valid value ID. + + Args: + valid_value_id (int): The valid value ID to search for. + + Returns: + Optional[SSDDReasonForChangeType]: The matching enum member, or None if not found. + """ + return next( + (item for item in cls if item.valid_value_id == valid_value_id), None + ) + + @classmethod + def by_description(cls, description: str) -> Optional["SSDDReasonForChangeType"]: + """ + Returns the enum member matching the given description. + + Args: + description (str): The description to search for. + + Returns: + Optional[SSDDReasonForChangeType]: The matching enum member, or None if not found. + """ + return next((item for item in cls if item.description == description), None) + + @classmethod + def by_description_case_insensitive( + cls, description: str + ) -> Optional["SSDDReasonForChangeType"]: + """ + Returns the enum member matching the given description (case-insensitive). + + Args: + description (str): The description to search for. + + Returns: + Optional[SSDDReasonForChangeType]: The matching enum member, or None if not found. + """ + return next( + (item for item in cls if item.description.lower() == description.lower()), + None, + ) diff --git a/classes/subject.py b/classes/subject.py new file mode 100644 index 00000000..45214cb2 --- /dev/null +++ b/classes/subject.py @@ -0,0 +1,1243 @@ +from typing import Optional, Union +from dataclasses import dataclass +from datetime import datetime, date +from sys import platform +from classes.address_contact_type import AddressContactType +from classes.address_type import AddressType +from classes.gender_type import GenderType +from classes.lynch_sdd_reason_for_change_type import LynchSDDReasonForChangeType +from classes.screening_status_type import ScreeningStatusType +from classes.sdd_reason_for_change_type import SDDReasonForChangeType +from classes.ss_reason_for_change_type import SSReasonForChangeType +from classes.ssdd_reason_for_change_type import SSDDReasonForChangeType + + +@dataclass +class Subject: + """ + Data class representing a subject in the screening system. + + Methods: + get and set methods for all attributes. + Utility methods for formatting and describing subject data. + """ + + screening_subject_id: Optional[int] = None + nhs_number: Optional[str] = None + surname: Optional[str] = None + forename: Optional[str] = None + datestamp: Optional[datetime] = None + screening_status_id: Optional[int] = None + screening_status_change_reason_id: Optional[int] = None + screening_status_change_date: Optional[date] = None + screening_due_date: Optional[date] = None + screening_due_date_change_reason_id: Optional[int] = None + screening_due_date_change_date: Optional[date] = None + calculated_screening_due_date: Optional[date] = None + surveillance_screening_due_date: Optional[date] = None + calculated_surveillance_due_date: Optional[date] = None + surveillance_due_date_change_reason_id: Optional[int] = None + surveillance_due_date_change_date: Optional[date] = None + lynch_due_date: Optional[date] = None + lynch_due_date_change_reason_id: Optional[int] = None + lynch_due_date_change_date: Optional[date] = None + calculated_lynch_due_date: Optional[date] = None + date_of_birth: Optional[date] = None + age: int = 0 + + other_names: Optional[str] = None + previous_surname: Optional[str] = None + title: Optional[str] = None + date_of_death: Optional[date] = None + gender: Optional["GenderType"] = None + address_type: Optional["AddressType"] = None + address_contact_type: Optional["AddressContactType"] = None + address_line1: Optional[str] = None + address_line2: Optional[str] = None + address_line3: Optional[str] = None + address_line4: Optional[str] = None + address_line5: Optional[str] = None + postcode: Optional[str] = None + address_effective_from: Optional[date] = None + address_effective_to: Optional[date] = None + registration_code: Optional[str] = None + gp_practice_id: Optional[int] = None + gp_practice_code: Optional[str] = None + nhais_deduction_reason: Optional[str] = None + nhais_deduction_date: Optional[date] = None + datasource: Optional[str] = None + removed_to_datasource: Optional[str] = None + audit_reason: Optional[str] = None + contact_id: Optional[int] = None + + def get_screening_subject_id(self) -> Optional[int]: + """ + Returns the screening subject ID. + + Returns: + Optional[int]: The screening subject ID. + """ + return self.screening_subject_id + + def set_screening_subject_id(self, screening_subject_id: int) -> None: + """ + Sets the screening subject ID. + + Args: + screening_subject_id (int): The screening subject ID to set. + """ + self.screening_subject_id = screening_subject_id + + def get_nhs_number(self) -> Optional[str]: + """ + Returns the NHS number. + + Returns: + Optional[str]: The NHS number. + """ + return self.nhs_number + + def get_nhs_number_spaced(self) -> Optional[str]: + """ + Returns the NHS number with spaces for readability if it is 10 digits. + + Returns: + Optional[str]: The formatted NHS number, or the original if not 10 digits. + """ + if self.nhs_number and len(self.nhs_number) == 10: + return f"{self.nhs_number[:3]} {self.nhs_number[3:6]} {self.nhs_number[6:]}" + return self.nhs_number + + def set_nhs_number(self, nhs_number: str): + """ + Sets the NHS number. + + Args: + nhs_number (str): The NHS number to set. + """ + self.nhs_number = nhs_number + + def get_surname(self) -> Optional[str]: + """ + Returns the surname. + + Returns: + Optional[str]: The surname. + """ + return self.surname + + def set_surname(self, surname: str) -> None: + """ + Sets the surname. + + Args: + surname (str): The surname to set. + """ + self.surname = surname + + def get_forename(self) -> Optional[str]: + """ + Returns the forename. + + Returns: + Optional[str]: The forename. + """ + return self.forename + + def set_forename(self, forname: str) -> None: + """ + Sets the forename. + + Args: + forname (str): The forename to set. + """ + self.forename = forname + + def get_datestamp(self) -> Optional[datetime]: + """ + Returns the datestamp. + + Returns: + Optional[datetime]: The datestamp. + """ + return self.datestamp + + def set_datestamp(self, datestamp: datetime) -> None: + """ + Sets the datestamp. + + Args: + datestamp (datetime): The datestamp to set. + """ + self.datestamp = datestamp + + def get_full_name(self) -> Optional[str]: + """ + Returns the full name, including title, forename, and surname. + + Returns: + Optional[str]: The full name. + """ + title_part = f"{self.title}" if self.title else "" + return f"{title_part} {self.forename} {self.surname}" + + def get_forename_surname(self) -> Optional[str]: + """ + Returns the forename and surname. + + Returns: + Optional[str]: The forename and surname. + """ + return f"{self.forename} {self.surname}" + + def get_name_and_nhs_number(self) -> Optional[str]: + """ + Returns the forename, surname, and NHS number. + + Returns: + Optional[str]: The formatted name and NHS number. + """ + return f"{self.forename} {self.surname} (NHS# {self.nhs_number})" + + def get_screening_status_id(self) -> Optional[int]: + """ + Returns the screening status ID. + + Returns: + Optional[int]: The screening status ID. + """ + return self.screening_status_id + + def set_screening_status_id(self, screening_status_id: int) -> None: + """ + Sets the screening status ID. + + Args: + screening_status_id (int): The screening status ID to set. + """ + self.screening_status_id = screening_status_id + + def get_screening_due_date(self) -> Optional[date]: + """ + Returns the screening due date. + + Returns: + Optional[date]: The screening due date. + """ + return self.screening_due_date + + def set_screening_due_date(self, screening_due_date: date) -> None: + """ + Sets the screening due date. + + Args: + screening_due_date (date): The screening due date to set. + """ + self.screening_due_date = screening_due_date + + def get_calculated_screening_due_date(self) -> Optional[date]: + """ + Returns the calculated screening due date. + + Returns: + Optional[date]: The calculated screening due date. + """ + return self.calculated_screening_due_date + + def set_calculated_screening_due_date( + self, calculated_screening_due_date: date + ) -> None: + """ + Sets the calculated screening due date. + + Args: + calculated_screening_due_date (date): The calculated screening due date to set. + """ + self.calculated_screening_due_date = calculated_screening_due_date + + def get_lynch_due_date(self) -> Optional[date]: + """ + Returns the Lynch due date. + + Returns: + Optional[date]: The Lynch due date. + """ + return self.lynch_due_date + + def set_lynch_due_date(self, lynch_due_date: date) -> None: + """ + Sets the Lynch due date. + + Args: + lynch_due_date (date): The Lynch due date to set. + """ + self.lynch_due_date = lynch_due_date + + def get_lynch_due_date_change_reason_id(self) -> Optional[int]: + """ + Returns the Lynch due date change reason ID. + + Returns: + Optional[int]: The Lynch due date change reason ID. + """ + return self.lynch_due_date_change_reason_id + + def set_lynch_due_date_change_reason_id( + self, lynch_due_date_change_reason_id: int + ) -> None: + """ + Sets the Lynch due date change reason ID. + + Args: + lynch_due_date_change_reason_id (int): The Lynch due date change reason ID to set. + """ + self.lynch_due_date_change_reason_id = lynch_due_date_change_reason_id + + def get_lynch_due_date_change_date(self) -> Optional[date]: + """ + Returns the Lynch due date change date. + + Returns: + Optional[date]: The Lynch due date change date. + """ + return self.lynch_due_date_change_date + + def set_lynch_due_date_change_date(self, lynch_due_date_change_date: date) -> None: + """ + Sets the Lynch due date change date. + + Args: + lynch_due_date_change_date (date): The Lynch due date change date to set. + """ + self.lynch_due_date_change_date = lynch_due_date_change_date + + def get_calculated_lynch_due_date(self) -> Optional[date]: + """ + Returns the calculated Lynch due date. + + Returns: + Optional[date]: The calculated Lynch due date. + """ + return self.calculated_lynch_due_date + + def set_calculated_lynch_due_date(self, calculated_lynch_due_date: date) -> None: + """ + Sets the calculated Lynch due date. + + Args: + calculated_lynch_due_date (date): The calculated Lynch due date to set. + """ + self.calculated_lynch_due_date = calculated_lynch_due_date + + def get_surveillance_screening_due_date(self) -> Optional[date]: + """ + Returns the surveillance screening due date. + + Returns: + Optional[date]: The surveillance screening due date. + """ + return self.surveillance_screening_due_date + + def set_surveillance_screening_due_date( + self, surveillance_screening_due_date: date + ): + """ + Sets the surveillance screening due date. + + Args: + surveillance_screening_due_date (date): The surveillance screening due date to set. + """ + self.surveillance_screening_due_date = surveillance_screening_due_date + + def get_calculated_surveillance_due_date(self) -> Optional[date]: + """ + Returns the calculated surveillance due date. + + Returns: + Optional[date]: The calculated surveillance due date. + """ + return self.calculated_surveillance_due_date + + def set_calculated_surveillance_due_date( + self, calculated_surveillance_due_date: date + ) -> None: + """ + Sets the calculated surveillance due date. + + Args: + calculated_surveillance_due_date (date): The calculated surveillance due date to set. + """ + self.calculated_surveillance_due_date = calculated_surveillance_due_date + + def get_date_of_birth(self) -> Optional[date]: + """ + Returns the date of birth. + + Returns: + Optional[date]: The date of birth. + """ + return self.date_of_birth + + def get_date_of_deth(self) -> Optional[date]: + """ + Returns the date of death. + + Returns: + Optional[date]: The date of death. + """ + return self.date_of_death + + def set_date_of_birth(self, dob: Optional[date]) -> None: + """ + Sets the date of birth and updates the age accordingly. + + Args: + dob (Optional[date]): The date of birth to set. + """ + self.date_of_birth = dob + if dob is None: + self.age = 0 + else: + today = date.today() + self.age = ( + today.year + - dob.year + - ((today.month, today.day) < (dob.month, dob.day)) + ) + + def get_age(self) -> Optional[int]: + """ + Returns the age. + + Returns: + Optional[int]: The age. + """ + return self.age + + def get_date_of_birth_string(self) -> Optional[str]: + """ + Returns the date of birth as a string in numeric format. + + Returns: + Optional[str]: The formatted date of birth. + """ + return self.get_date_as_string(self.date_of_birth, False) + + def get_date_of_birth_string_text_month(self) -> Optional[str]: + """ + Returns the date of birth as a string with the month as text. + + Returns: + Optional[str]: The formatted date of birth. + """ + return self.get_date_as_string(self.date_of_birth, True) + + def get_date_of_birth_with_age(self) -> Optional[str]: + """ + Returns the date of birth string with age. + + Returns: + Optional[str]: The formatted date of birth with age. + """ + return f"{self.get_date_of_birth_string()} (age: {self.age})" + + def get_date_of_birth_text_month_with_age(self) -> Optional[str]: + """ + Returns the date of birth string with month as text and age. + + Returns: + Optional[str]: The formatted date of birth with age. + """ + return f"{self.get_date_of_birth_string_text_month()} (age: {self.age})" + + def get_date_of_death_string(self) -> Optional[str]: + """ + Returns the date of death as a string. + + Returns: + Optional[str]: The formatted date of death. + """ + return self.get_date_as_string(self.date_of_death, False) + + def get_screening_status_change_date_string(self) -> Optional[str]: + """ + Returns the screening status change date as a string. + + Returns: + Optional[str]: The formatted screening status change date. + """ + return self.get_date_as_string(self.screening_status_change_date, False) + + def get_screening_due_date_string(self) -> Optional[str]: + """ + Returns the screening due date as a string. + + Returns: + Optional[str]: The formatted screening due date. + """ + return self.get_date_as_string(self.screening_due_date, False) + + def get_screening_due_date_change_date_string(self) -> Optional[str]: + """ + Returns the screening due date change date as a string. + + Returns: + Optional[str]: The formatted screening due date change date. + """ + return self.get_date_as_string(self.screening_due_date_change_date, False) + + def get_lynch_due_date_change_date_string(self) -> Optional[str]: + """ + Returns the Lynch due date change date as a string. + + Returns: + Optional[str]: The formatted Lynch due date change date. + """ + return self.get_date_as_string(self.lynch_due_date_change_date, False) + + def get_calculated_screening_due_date_string(self) -> Optional[str]: + """ + Returns the calculated screening due date as a string. + + Returns: + Optional[str]: The formatted calculated screening due date. + """ + return self.get_date_as_string(self.calculated_screening_due_date, False) + + def get_calculated_lynch_due_date_string(self) -> Optional[str]: + """ + Returns the calculated Lynch due date as a string. + + Returns: + Optional[str]: The formatted calculated Lynch due date. + """ + return self.get_date_as_string(self.calculated_lynch_due_date, False) + + def get_surveillance_screening_due_date_string(self) -> Optional[str]: + """ + Returns the surveillance screening due date as a string. + + Returns: + Optional[str]: The formatted surveillance screening due date. + """ + return self.get_date_as_string(self.surveillance_screening_due_date, False) + + def get_calculated_surveillance_due_date_string(self) -> Optional[str]: + """ + Returns the calculated surveillance due date as a string. + + Returns: + Optional[str]: The formatted calculated surveillance due date. + """ + return self.get_date_as_string(self.calculated_surveillance_due_date, False) + + def get_surveillance_due_date_change_date_string(self) -> Optional[str]: + """ + Returns the surveillance due date change date as a string. + + Returns: + Optional[str]: The formatted surveillance due date change date. + """ + return self.get_date_as_string(self.surveillance_due_date_change_date, False) + + def get_lynch_due_date_string(self) -> Optional[str]: + """ + Returns the Lynch due date as a string. + + Returns: + Optional[str]: The formatted Lynch due date. + """ + return self.get_date_as_string(self.lynch_due_date, False) + + def get_screening_status_id_desc(self) -> Optional[str]: + """ + Returns the screening status ID and its description. + + Returns: + Optional[str]: The formatted screening status ID and description. + """ + if self.screening_status_id is not None: + status_type = ScreeningStatusType.by_valid_value_id( + self.screening_status_id + ) + if status_type is not None: + description = status_type.description + return f"{self.screening_status_id} {description}" + return None + + def get_screening_status_change_reason_id_desc(self) -> Optional[str]: + """ + Returns the screening status change reason ID and its description. + + Returns: + Optional[str]: The formatted screening status change reason ID and description. + """ + if self.screening_status_change_reason_id is not None: + reason_type = SSReasonForChangeType.by_valid_value_id( + self.screening_status_change_reason_id + ) + if reason_type is not None: + description = reason_type.description + return f"{self.screening_status_change_reason_id} {description}" + return None + + def get_screening_due_date_change_reason_id_desc(self) -> Optional[str]: + """ + Returns the screening due date change reason ID and its description. + + Returns: + Optional[str]: The formatted screening due date change reason ID and description. + """ + if self.screening_due_date_change_reason_id is not None: + reason_type = SDDReasonForChangeType.by_valid_value_id( + self.screening_due_date_change_reason_id + ) + if reason_type is not None: + description = reason_type.description + return f"{self.screening_due_date_change_reason_id} {description}" + return None + + def get_lynch_due_date_change_reason_id_desc(self) -> Optional[str]: + """ + Returns the Lynch due date change reason ID and its description. + + Returns: + Optional[str]: The formatted Lynch due date change reason ID and description. + """ + if self.lynch_due_date_change_reason_id is not None: + reason_type = LynchSDDReasonForChangeType.by_valid_value_id( + self.lynch_due_date_change_reason_id + ) + if reason_type is not None: + description = reason_type.description + return f"{self.lynch_due_date_change_reason_id} {description}" + return None + + def get_surveillance_due_date_change_reason_id_desc(self) -> Optional[str]: + """ + Returns the surveillance due date change reason ID and its description. + + Returns: + Optional[str]: The formatted surveillance due date change reason ID and description. + """ + if self.surveillance_due_date_change_reason_id is not None: + reason_type = SSDDReasonForChangeType.by_valid_value_id( + self.surveillance_due_date_change_reason_id + ) + if reason_type is not None: + description = reason_type.description + return f"{self.surveillance_due_date_change_reason_id} {description}" + return None + + def get_date_as_string( + self, date_to_convert: Optional[Union[date, datetime]], month_as_text: bool + ) -> Optional[str]: + """ + Returns a date as a string, optionally with the month as text. + + Args: + date_to_convert (Optional[Union[date, datetime]]): The date to convert. + month_as_text (bool): Whether to use the month as text. + + Returns: + Optional[str]: The formatted date string. + """ + if date_to_convert is None: + return None + + if isinstance(date_to_convert, date) and not isinstance( + date_to_convert, datetime + ): + date_to_convert = datetime.combine(date_to_convert, datetime.min.time()) + + if platform == "win32": # Windows: + format_to_use = "%#d %b %Y" if month_as_text else "%d/%m/%Y" + else: + format_to_use = "%-d %b %Y" if month_as_text else "%d/%m/%Y" + return date_to_convert.strftime(format_to_use) + + def get_other_names(self) -> Optional[str]: + """ + Returns other names. + + Returns: + Optional[str]: The other names. + """ + return self.other_names + + def set_other_names(self, other_names: str) -> None: + """ + Sets other names. + + Args: + other_names (str): The other names to set. + """ + self.other_names = other_names + + def get_previous_surname(self) -> Optional[str]: + """ + Returns the previous surname. + + Returns: + Optional[str]: The previous surname. + """ + return self.previous_surname + + def set_previous_surname(self, previous_surname: str) -> None: + """ + Sets the previous surname. + + Args: + previous_surname (str): The previous surname to set. + """ + self.previous_surname = previous_surname + + def get_title(self) -> Optional[str]: + """ + Returns the title. + + Returns: + Optional[str]: The title. + """ + return self.title + + def set_title(self, title: str) -> None: + """ + Sets the title. + + Args: + title (str): The title to set. + """ + self.title = title + + def get_gender(self) -> Optional["GenderType"]: + """ + Returns the gender. + + Returns: + Optional[GenderType]: The gender. + """ + return self.gender + + def set_gender(self, gender: "GenderType") -> None: + """ + Sets the gender. + + Args: + gender (GenderType): The gender to set. + """ + self.gender = gender + + def get_address_line_1(self) -> Optional[str]: + """ + Returns address line 1. + + Returns: + Optional[str]: Address line 1. + """ + return self.address_line_1 + + def set_address_line_1(self, address_line_1: str) -> None: + """ + Sets address line 1. + + Args: + address_line_1 (str): Address line 1 to set. + """ + self.address_line_1 = address_line_1 + + def get_address_line_2(self) -> Optional[str]: + """ + Returns address line 2. + + Returns: + Optional[str]: Address line 2. + """ + return self.address_line_2 + + def set_address_line_2(self, address_line_2: str) -> None: + """ + Sets address line 2. + + Args: + address_line_2 (str): Address line 2 to set. + """ + self.address_line_2 = address_line_2 + + def get_address_line_3(self) -> Optional[str]: + """ + Returns address line 3. + + Returns: + Optional[str]: Address line 3. + """ + return self.address_line_3 + + def set_address_line_3(self, address_line_3: str) -> None: + """ + Sets address line 3. + + Args: + address_line_3 (str): Address line 3 to set. + """ + self.address_line_3 = address_line_3 + + def get_address_line_4(self) -> Optional[str]: + """ + Returns address line 4. + + Returns: + Optional[str]: Address line 4. + """ + return self.address_line_4 + + def set_address_line_4(self, address_line_4: str) -> None: + """ + Sets address line 4. + + Args: + address_line_4 (str): Address line 4 to set. + """ + self.address_line_4 = address_line_4 + + def get_address_line_5(self) -> Optional[str]: + """ + Returns address line 5. + + Returns: + Optional[str]: Address line 5. + """ + return self.address_line_5 + + def set_address_line_5(self, address_line_5: str) -> None: + """ + Sets address line 5. + + Args: + address_line_5 (str): Address line 5 to set. + """ + self.address_line_5 = address_line_5 + + def get_address_type(self) -> Optional["AddressType"]: + """ + Returns the address type. + + Returns: + Optional[AddressType]: The address type. + """ + return self.address_type + + def set_address_type(self, address_type: "AddressType") -> None: + """ + Sets the address type. + + Args: + address_type (AddressType): The address type to set. + """ + self.address_type = address_type + + def get_address_contact_type(self) -> Optional["AddressContactType"]: + """ + Returns the address contact type. + + Returns: + Optional[AddressContactType]: The address contact type. + """ + return self.address_contact_type + + def set_address_contact_type( + self, address_contact_type: "AddressContactType" + ) -> None: + """ + Sets the address contact type. + + Args: + address_contact_type (AddressContactType): The address contact type to set. + """ + self.address_contact_type = address_contact_type + + def get_postcode(self) -> Optional[str]: + """ + Returns the postcode. + + Returns: + Optional[str]: The postcode. + """ + return self.postcode + + def set_postcode(self, postcode: str) -> None: + """ + Sets the postcode. + + Args: + postcode (str): The postcode to set. + """ + self.postcode = postcode + + def get_address_effective_from(self) -> Optional[date]: + """ + Returns the address effective from date. + + Returns: + Optional[date]: The address effective from date. + """ + return self.address_effective_from + + def set_address_effective_from(self, address_effective_from: date) -> None: + """ + Sets the address effective from date. + + Args: + address_effective_from (date): The address effective from date to set. + """ + self.address_effective_from = address_effective_from + + def get_address_effective_to(self) -> Optional[date]: + """ + Returns the address effective to date. + + Returns: + Optional[date]: The address effective to date. + """ + return self.address_effective_to + + def set_address_effective_to(self, address_effective_to: date) -> None: + """ + Sets the address effective to date. + + Args: + address_effective_to (date): The address effective to date to set. + """ + self.address_effective_to = address_effective_to + + def get_registration_code(self) -> Optional[str]: + """ + Returns the registration code. + + Returns: + Optional[str]: The registration code. + """ + return self.registration_code + + def set_registration_code(self, registration_code: str) -> None: + """ + Sets the registration code. + + Args: + registration_code (str): The registration code to set. + """ + self.registration_code = registration_code + + def get_gp_practice_id(self) -> Optional[int]: + """ + Returns the GP practice ID. + + Returns: + Optional[int]: The GP practice ID. + """ + return self.gp_practice_id + + def set_gp_practice_id(self, gp_practice_id: int) -> None: + """ + Sets the GP practice ID. + + Args: + gp_practice_id (int): The GP practice ID to set. + """ + self.gp_practice_id = gp_practice_id + + def get_gp_practice_code(self) -> Optional[str]: + """ + Returns the GP practice code. + + Returns: + Optional[str]: The GP practice code. + """ + return self.gp_practice_code + + def set_gp_practice_code(self, gp_practice_code: str) -> None: + """ + Sets the GP practice code. + + Args: + gp_practice_code (str): The GP practice code to set. + """ + self.gp_practice_code = gp_practice_code + + def get_nhais_deduction_reason(self) -> Optional[str]: + """ + Returns the NHAIS deduction reason. + + Returns: + Optional[str]: The NHAIS deduction reason. + """ + return self.nhais_deduction_reason + + def set_nhais_deduction_reason(self, nhais_deduction_reason: str) -> None: + """ + Sets the NHAIS deduction reason. + + Args: + nhais_deduction_reason (str): The NHAIS deduction reason to set. + """ + self.nhais_deduction_reason = nhais_deduction_reason + + def get_nhais_deduction_date(self) -> Optional[date]: + """ + Returns the NHAIS deduction date. + + Returns: + Optional[date]: The NHAIS deduction date. + """ + return self.nhais_deduction_date + + def set_nhais_deduction_date(self, nhais_deduction_date: date) -> None: + """ + Sets the NHAIS deduction date. + + Args: + nhais_deduction_date (date): The NHAIS deduction date to set. + """ + self.nhais_deduction_date = nhais_deduction_date + + def get_datasource(self) -> Optional[str]: + """ + Returns the data source. + + Returns: + Optional[str]: The data source. + """ + return self.datasource + + def set_datasource(self, datasource: str) -> None: + """ + Sets the data source. + + Args: + datasource (str): The data source to set. + """ + self.datasource = datasource + + def get_removed_to_datasource(self) -> Optional[str]: + """ + Returns the removed to data source. + + Returns: + Optional[str]: The removed to data source. + """ + return self.removed_to_datasource + + def set_removed_to_datasource(self, removed_to_datasource: str) -> None: + """ + Sets the removed to data source. + + Args: + removed_to_datasource (str): The removed to data source to set. + """ + self.removed_to_datasource = removed_to_datasource + + def get_audit_reason(self) -> Optional[str]: + """ + Returns the audit reason. + + Returns: + Optional[str]: The audit reason. + """ + return self.audit_reason + + def set_audit_reason(self, audit_reason: str) -> None: + """ + Sets the audit reason. + + Args: + audit_reason (str): The audit reason to set. + """ + self.audit_reason = audit_reason + + def get_contact_id(self) -> Optional[int]: + """ + Returns the contact ID. + + Returns: + Optional[int]: The contact ID. + """ + return self.contact_id + + def set_contact_id(self, contact_id: int) -> None: + """ + Sets the contact ID. + + Args: + contact_id (int): The contact ID to set. + """ + self.contact_id = contact_id + + def get_screening_status_change_reason_id(self) -> Optional[int]: + """ + Returns the screening status change reason ID. + + Returns: + Optional[int]: The screening status change reason ID. + """ + return self.screening_status_change_reason_id + + def set_screening_status_change_reason_id( + self, screening_status_change_reason_id: int + ) -> None: + """ + Sets the screening status change reason ID. + + Args: + screening_status_change_reason_id (int): The screening status change reason ID to set. + """ + self.screening_status_change_reason_id = screening_status_change_reason_id + + def get_screening_status_change_date(self) -> Optional[date]: + """ + Returns the screening status change date. + + Returns: + Optional[date]: The screening status change date. + """ + return self.screening_status_change_date + + def set_screening_status_change_date( + self, screening_status_change_date: date + ) -> None: + """ + Sets the screening status change date. + + Args: + screening_status_change_date (date): The screening status change date to set. + """ + self.screening_status_change_date = screening_status_change_date + + def get_screening_due_date_change_reason_id(self) -> Optional[int]: + """ + Returns the screening due date change reason ID. + + Returns: + Optional[int]: The screening due date change reason ID. + """ + return self.screening_due_date_change_reason_id + + def set_screening_due_date_change_reason_id( + self, screening_due_date_change_reason_id: int + ) -> None: + """ + Sets the screening due date change reason ID. + + Args: + screening_due_date_change_reason_id (int): The screening due date change reason ID to set. + """ + self.screening_due_date_change_reason_id = screening_due_date_change_reason_id + + def get_screening_due_date_change_date(self) -> Optional[date]: + """ + Returns the screening due date change date. + + Returns: + Optional[date]: The screening due date change date. + """ + return self.screening_due_date_change_date + + def set_screening_due_date_change_date( + self, screening_due_date_change_date: date + ) -> None: + """ + Sets the screening due date change date. + + Args: + screening_due_date_change_date (date): The screening due date change date to set. + """ + self.screening_due_date_change_date = screening_due_date_change_date + + def get_surveillance_due_date_change_reason_id(self) -> Optional[int]: + """ + Returns the surveillance due date change reason ID. + + Returns: + Optional[int]: The surveillance due date change reason ID. + """ + return self.surveillance_due_date_change_reason_id + + def set_surveillance_due_date_change_reason_id( + self, surveillance_due_date_change_reason_id: int + ) -> None: + """ + Sets the surveillance due date change reason ID. + + Args: + surveillance_due_date_change_reason_id (int): The surveillance due date change reason ID to set. + """ + self.surveillance_due_date_change_reason_id = ( + surveillance_due_date_change_reason_id + ) + + def get_surveillance_due_date_change_date(self) -> Optional[date]: + """ + Returns the surveillance due date change date. + + Returns: + Optional[date]: The surveillance due date change date. + """ + return self.surveillance_due_date_change_date + + def set_surveillance_due_date_change_date( + self, surveillance_due_date_change_date: date + ) -> None: + """ + Sets the surveillance due date change date. + + Args: + surveillance_due_date_change_date (date): The surveillance due date change date to set. + """ + self.surveillance_due_date_change_date = surveillance_due_date_change_date + + def value_or_null(self, value): + """ + Returns "null" if the value is None, otherwise returns the value. + + Args: + value: The value to check. + + Returns: + Any: "null" if value is None, else the value. + """ + return "null" if value is None else value + + def __str__(self): + """ + Returns a string representation of the Subject object. + + Returns: + str: The string representation of the subject. + """ + return ( + f"Subject [" + f"screeningSubjectId={self.screening_subject_id}, " + f"nhsNumber={self.nhs_number}, " + f"forename={self.forename}, " + f"surname={self.surname}, " + f"dateOfBirth={self.value_or_null(self.get_date_of_birth_with_age())}, " + f"dateOfDeath={self.value_or_null(self.get_date_of_death_string())}, " + f"screeningStatusId={self.value_or_null(self.get_screening_status_id_desc())}, " + f"screeningStatusChangeReasonId={self.value_or_null(self.get_screening_status_change_reason_id_desc())}, " + f"screeningStatusChangeDate={self.value_or_null(self.get_screening_status_change_date_string())}, " + f"screeningDueDate={self.value_or_null(self.get_screening_due_date_string())}, " + f"screeningDueDateChangeReasonId={self.value_or_null(self.get_screening_due_date_change_reason_id_desc())}, " + f"screeningDueDateChangeDate={self.value_or_null(self.get_screening_due_date_change_date_string())}, " + f"calculatedScreeningDueDate={self.value_or_null(self.get_calculated_screening_due_date_string())}, " + f"surveillanceScreeningDueDate={self.value_or_null(self.get_surveillance_screening_due_date_string())}, " + f"calculatedSurveillanceDueDate={self.value_or_null(self.get_calculated_surveillance_due_date_string())}, " + f"surveillanceDueDateChangeReasonId={self.value_or_null(self.get_surveillance_due_date_change_reason_id_desc())}, " + f"surveillanceDueDateChangeDate={self.value_or_null(self.get_surveillance_due_date_change_date_string())}, " + f"lynchDueDate={self.value_or_null(self.get_lynch_due_date_string())}, " + f"lynchDueDateChangeReasonId={self.value_or_null(self.get_lynch_due_date_change_reason_id_desc())}, " + f"lynchDueDateChangeDate={self.value_or_null(self.get_lynch_due_date_change_date_string())}, " + f"calculatedLynchDueDate={self.value_or_null(self.get_calculated_lynch_due_date_string())}, " + f"gpPracticeCode={self.value_or_null(self.gp_practice_code)}, " + f"nhaisDeductionReason={self.value_or_null(self.nhais_deduction_reason)}, " + f"datestamp={self.datestamp}" + f"]" + ) diff --git a/classes/subject_has_episode.py b/classes/subject_has_episode.py new file mode 100644 index 00000000..b529216b --- /dev/null +++ b/classes/subject_has_episode.py @@ -0,0 +1,46 @@ +from enum import Enum +from typing import Optional + + +class SubjectHasEpisode(Enum): + """ + Enum representing whether a subject has an episode. + + Members: + YES: Subject has an episode. + NO: Subject does not have an episode. + + Methods: + by_description(description: str) -> Optional[SubjectHasEpisode]: + Returns the enum member matching the given description, or None if not found. + get_description() -> str: + Returns the string description of the enum member. + """ + + YES = "yes" + NO = "no" + + @classmethod + def by_description(cls, description: str) -> Optional["SubjectHasEpisode"]: + """ + Returns the enum member matching the given description. + + Args: + description (str): The description to search for. + + Returns: + Optional[SubjectHasEpisode]: The matching enum member, or None if not found. + """ + for item in cls: + if item.value == description: + return item + return None + + def get_description(self) -> str: + """ + Returns the string description of the enum member. + + Returns: + str: The description value. + """ + return self.value diff --git a/classes/subject_hub_code.py b/classes/subject_hub_code.py new file mode 100644 index 00000000..a42238d4 --- /dev/null +++ b/classes/subject_hub_code.py @@ -0,0 +1,48 @@ +from enum import Enum +from typing import Dict, Optional + + +class SubjectHubCode(Enum): + """ + Enum representing subject hub code types. + + Members: + USER_HUB: Represents the user's hub. + USER_ORGANISATION: Represents the user's organisation. + + Methods: + description: Returns the string description of the enum member. + by_description(description: str) -> Optional[SubjectHubCode]: + Returns the enum member matching the given description, or None if not found. + """ + + USER_HUB = "user's hub" + USER_ORGANISATION = "user's organisation" + + @property + def description(self) -> str: + """ + Returns the string description of the enum member. + + Returns: + str: The description value. + """ + return self.value + + @classmethod + def by_description(cls, description: str) -> Optional["SubjectHubCode"]: + """ + Returns the enum member matching the given description. + + Args: + description (str): The description to search for. + + Returns: + Optional[SubjectHubCode]: The matching enum member, or None if not found. + """ + # Build reverse lookup map once and store it as a class attribute + if not hasattr(cls, "_description_map"): + cls._description_map: Dict[str, SubjectHubCode] = { + member.value: member for member in cls + } + return cls._description_map.get(description) diff --git a/classes/subject_screening_centre_code.py b/classes/subject_screening_centre_code.py new file mode 100644 index 00000000..51dce174 --- /dev/null +++ b/classes/subject_screening_centre_code.py @@ -0,0 +1,72 @@ +from enum import Enum + + +class SubjectScreeningCentreCode(Enum): + """ + Enum representing subject screening centre code types. + + Members: + NONE: No screening centre. + NULL: Null value for subject selection criteria. + NOT_NULL: Not Null value for subject selection criteria. + USER_SC: User's screening centre (abbreviated). + USER_SCREENING_CENTRE: User's screening centre (full). + USER_ORGANISATION: User's organisation. + + Methods: + description: Returns the string description of the enum member. + by_description(description: str) -> Optional[SubjectScreeningCentreCode]: + Returns the enum member matching the given description, or None if not found. + by_description_case_insensitive(description: str) -> Optional[SubjectScreeningCentreCode]: + Returns the enum member matching the given description (case-insensitive), or None if not found. + """ + + NONE = "None" + NULL = "Null" + NOT_NULL = "Not null" + USER_SC = "User's SC" + USER_SCREENING_CENTRE = "User's screening centre" + USER_ORGANISATION = "User's organisation" + + @property + def description(self): + """ + Returns the string description of the enum member. + + Returns: + str: The description value. + """ + return self.value + + @staticmethod + def by_description(description: str): + """ + Returns the enum member matching the given description. + + Args: + description (str): The description to search for. + + Returns: + Optional[SubjectScreeningCentreCode]: The matching enum member, or None if not found. + """ + for item in SubjectScreeningCentreCode: + if item.description == description: + return item + return None # or raise an exception + + @staticmethod + def by_description_case_insensitive(description: str): + """ + Returns the enum member matching the given description (case-insensitive). + + Args: + description (str): The description to search for. + + Returns: + Optional[SubjectScreeningCentreCode]: The matching enum member, or None if not found. + """ + description_lower = description.lower() + for item in SubjectScreeningCentreCode: + if item.description.lower() == description_lower: + return item + return None # or raise an exception diff --git a/classes/subject_selection_criteria_key.py b/classes/subject_selection_criteria_key.py new file mode 100644 index 00000000..42cfe2a2 --- /dev/null +++ b/classes/subject_selection_criteria_key.py @@ -0,0 +1,367 @@ +from enum import Enum +from typing import Dict, Optional + + +class SubjectSelectionCriteriaKey(Enum): + """ + Enum representing all possible subject selection criteria keys. + + Each member is a tuple: + (description: str, allow_not_modifier: bool, allow_more_than_one_value: bool) + + Members: + Each member represents a subject selection criteria key, its description, and flags indicating + if the "not" modifier is allowed and if more than one value is allowed. + + Properties: + description: Returns the string description of the criteria key. + allow_not_modifier: Returns True if the "not" modifier is allowed for this key. + allow_more_than_one_value: Returns True if more than one value is allowed for this key. + + Methods: + by_description(description: str) -> Optional[SubjectSelectionCriteriaKey]: + Returns the enum member matching the given description, or None if not found. + """ + + APPOINTMENT_DATE = ("appointment date", False, True) + APPOINTMENT_STATUS = ("appointment status", True, True) + APPOINTMENT_TYPE = ("appointment type", False, True) + BOWEL_SCOPE_DUE_DATE_REASON = ("bowel scope due date reason", True, True) + CADS_ASA_GRADE = ("cads asa grade", False, False) + CADS_STAGING_SCANS = ("cads staging scans", False, False) + CADS_TYPE_OF_SCAN = ("cads type of scan", False, False) + CADS_METASTASES_PRESENT = ("cads metastases present", False, False) + CADS_METASTASES_LOCATION = ("cads metastases location", False, False) + CADS_METASTASES_OTHER_LOCATION = ("cads metastases other location", False, False) + CADS_FINAL_PRE_TREATMENT_T_CATEGORY = ( + "cads final pre-treatment t category", + False, + False, + ) + CADS_FINAL_PRE_TREATMENT_N_CATEGORY = ( + "cads final pre-treatment n category", + False, + False, + ) + CADS_FINAL_PRETREATMENT_M_CATEGORY = ( + "cads final pre-treatment m category", + False, + False, + ) + CADS_TREATMENT_RECEIVED = ("cads treatment received", False, False) + CADS_REASON_NO_TREATMENT_RECEIVED = ( + "cads reason no treatment received", + False, + False, + ) + CADS_TUMOUR_DATE_OF_DIAGNOSIS = ("cads tumour date of diagnosis", False, False) + CADS_TUMOUR_LOCATION = ("cads tumour location", False, False) + CADS_TUMOUR_HEIGHT_OF_TUMOUR_ABOVE_ANAL_VERGE = ( + "cads height of tumour above anal verge", + False, + False, + ) + CADS_TUMOUR_PREVIOUSLY_EXCISED_TUMOUR = ( + "cads previously excised tumour (recurrence)", + False, + False, + ) + CADS_TREATMENT_START_DATE = ("cads date of treatment", False, False) + CADS_TREATMENT_TYPE = ("cads treatment type", False, False) + CADS_TREATMENT_GIVEN = ("cads treatment given", False, False) + CADS_CANCER_TREATMENT_INTENT = ("cads cancer treatment intent", False, False) + CADS_TREATMENT_PROVIDER = ("cads treatment provider", False, False) + CADS_TREATMENT_CONSULTANT = ("cads treatment consultant", False, False) + CALCULATED_FOBT_DUE_DATE = ("calculated fobt due date", False, False) + CALCULATED_LYNCH_DUE_DATE = ("calculated lynch due date", False, False) + CALCULATED_SCREENING_DUE_DATE = ("calculated screening due date", False, False) + CALCULATED_SCREENING_DUE_DATE_BIRTHDAY = ( + "calculated screening due date (birthday)", + False, + False, + ) + CALCULATED_SURVEILLANCE_DUE_DATE = ( + "calculated surveillance due date", + False, + False, + ) + CEASED_CONFIRMATION_DATE = ("ceased confirmation date", False, False) + CEASED_CONFIRMATION_DETAILS = ("ceased confirmation details", True, False) + CEASED_CONFIRMATION_USER_ID = ("ceased confirmation user id", True, False) + CLINICAL_REASON_FOR_CEASE = ("clinical reason for cease", True, False) + DATE_OF_DEATH = ("date of death", False, False) + DEMOGRAPHICS_TEMPORARY_ADDRESS = ("subject has temporary address", False, False) + DIAGNOSTIC_TEST_CONFIRMED_DATE = ("diagnostic test confirmed date", False, False) + DIAGNOSTIC_TEST_CONFIRMED_TYPE = ("diagnostic test confirmed type", True, True) + DIAGNOSTIC_TEST_HAS_OUTCOME = ("diagnostic test has outcome", False, True) + DIAGNOSTIC_TEST_HAS_RESULT = ("diagnostic test has result", False, True) + DIAGNOSTIC_TEST_INTENDED_EXTENT = ("diagnostic test intended extent", True, False) + DIAGNOSTIC_TEST_IS_VOID = ("diagnostic test is void", False, True) + DIAGNOSTIC_TEST_PROPOSED_TYPE = ("diagnostic test proposed type", True, True) + FOBT_PREVALENT_INCIDENT_STATUS = ("fobt prevalent/incident status", False, False) + HAS_DIAGNOSTIC_TEST_CONTAINING_POLYP = ( + "has diagnostic test containing polyp", + False, + False, + ) + HAS_EXISTING_SURVEILLANCE_REVIEW_CASE = ( + "has existing surveillance review case", + False, + False, + ) + HAS_GP_PRACTICE = ("has gp practice", False, False) + HAS_GP_PRACTICE_ASSOCIATED_WITH_SCREENING_CENTRE_CODE = ( + "has gp practice associated with screening centre code", + False, + False, + ) + HAS_HAD_A_DATE_OF_DEATH_REMOVAL = ("has had a date of death removal", False, False) + HAS_PREVIOUSLY_HAD_CANCER = ("has previously had cancer", False, False) + INVITED_SINCE_AGE_EXTENSION = ("invited since age extension", False, False) + KIT_HAS_ANALYSER_RESULT_CODE = ("kit has analyser result code", False, True) + KIT_HAS_BEEN_READ = ("kit has been read", False, True) + KIT_RESULT = ("kit result", True, True) + LATEST_EPISODE_ACCUMULATED_RESULT = ( + "latest episode accumulated result", + True, + False, + ) + LATEST_EPISODE_COMPLETED_SATISFACTORILY = ( + "latest episode completed satisfactorily", + False, + False, + ) + LATEST_EPISODE_DATASET_INTENDED_EXTENT = ( + "latest episode dataset intended extent", + False, + False, + ) + LATEST_EPISODE_DIAGNOSIS_DATE_REASON = ( + "latest episode diagnosis date reason", + True, + True, + ) + LATEST_EPISODE_DOES_NOT_INCLUDE_EVENT_CODE = ( + "latest episode does not include event code", + False, + True, + ) + LATEST_EPISODE_DOES_NOT_INCLUDE_EVENT_STATUS = ( + "latest episode does not include event status", + False, + True, + ) + LATEST_EPISODE_ENDED = ("latest episode ended", False, False) + LATEST_EPISODE_HAS_CANCER_AUDIT_DATASET = ( + "latest episode has cancer audit dataset", + False, + False, + ) + LATEST_EPISODE_HAS_COLONOSCOPY_ASSESSMENT_DATASET = ( + "latest episode has colonoscopy assessment dataset", + False, + False, + ) + LATEST_EPISODE_HAS_MDT_DATASET = ("latest episode has mdt dataset", False, False) + LATEST_EPISODE_HAS_DIAGNOSIS_DATE = ( + "latest episode has diagnosis date", + False, + False, + ) + LATEST_EPISODE_HAS_DIAGNOSTIC_TEST = ( + "latest episode has diagnostic test", + False, + False, + ) + LATEST_EPISODE_HAS_REFERRAL_DATE = ( + "latest episode has referral date", + False, + False, + ) + LATEST_EPISODE_HAS_SIGNIFICANT_KIT_RESULT = ( + "latest episode has significant kit result", + False, + False, + ) + LATEST_EPISODE_INCLUDES_EVENT_STATUS = ( + "latest episode includes event status", + False, + True, + ) + LATEST_EPISODE_INCLUDES_EVENT_CODE = ( + "latest episode includes event code", + False, + True, + ) + LATEST_EPISODE_KIT_CLASS = ("latest episode kit class", True, False) + LATEST_EPISODE_LATEST_INVESTIGATION_DATASET = ( + "latest episode latest investigation dataset", + False, + False, + ) + LATEST_EPISODE_RECALL_CALCULATION_METHOD = ( + "latest episode recall calculation method", + True, + False, + ) + LATEST_EPISODE_RECALL_EPISODE_TYPE = ( + "latest episode recall episode type", + True, + False, + ) + LATEST_EPISODE_RECALL_SURVEILLANCE_TYPE = ( + "latest episode recall surveillance type", + True, + False, + ) + LATEST_EPISODE_STARTED = ("latest episode started", False, False) + LATEST_EPISODE_STATUS = ("latest episode status", True, False) + LATEST_EPISODE_STATUS_REASON = ("latest episode status reason", True, True) + LATEST_EPISODE_SUB_TYPE = ("latest episode sub-type", True, False) + LATEST_EPISODE_TYPE = ("latest episode type", True, False) + LATEST_EVENT_STATUS = ("latest event status", True, False) + LYNCH_DIAGNOSIS_DATE = ("lynch diagnosis date", False, False) + LYNCH_DUE_DATE = ("lynch due date", False, False) + LYNCH_DUE_DATE_DATE_OF_CHANGE = ("lynch due date date of change", False, False) + LYNCH_DUE_DATE_REASON = ("lynch due date reason", True, True) + LYNCH_INCIDENT_EPISODE = ("lynch incident episode", False, False) + LYNCH_LAST_COLONOSCOPY_DATE = ("lynch last colonoscopy date", False, False) + MANUAL_CEASE_REQUESTED = ("manual cease requested", False, False) + NOTE_COUNT = ("note count", False, False) + NOTIFY_ARCHIVED_MESSAGE_STATUS = ("notify archived message status", False, False) + NOTIFY_QUEUED_MESSAGE_STATUS = ("notify queued message status", False, False) + NHS_NUMBER = ("nhs number", False, False) + PRE_INTERRUPT_EVENT_STATUS = ("pre-interrupt event status", True, True) + PREVIOUS_LYNCH_DUE_DATE = ("previous lynch due date", False, False) + PREVIOUS_SCREENING_DUE_DATE = ("previous screening due date", False, False) + PREVIOUS_SCREENING_DUE_DATE_BIRTHDAY = ( + "previous screening due date (birthday)", + False, + False, + ) + PREVIOUS_SCREENING_STATUS = ("previous screening status", True, True) + PREVIOUS_SURVEILLANCE_DUE_DATE = ("previous surveillance due date", False, False) + RESPONSIBLE_SCREENING_CENTRE_CODE = ( + "responsible screening centre code", + True, + False, + ) + SCREENING_DUE_DATE = ("screening due date", False, False) + SCREENING_DUE_DATE_BIRTHDAY = ("screening due date (birthday)", False, False) + SCREENING_DUE_DATE_DATE_OF_CHANGE = ( + "screening due date date of change", + False, + False, + ) + SCREENING_DUE_DATE_REASON = ("screening due date reason", True, True) + SCREENING_REFERRAL_TYPE = ("screening referral type", False, False) + SCREENING_STATUS = ("screening status", True, True) + SCREENING_STATUS_DATE_OF_CHANGE = ("screening status date of change", False, False) + SCREENING_STATUS_REASON = ("screening status reason", True, True) + SYMPTOMATIC_PROCEDURE_DATE = ("symptomatic procedure date", False, False) + SYMPTOMATIC_PROCEDURE_RESULT = ("symptomatic procedure result", False, False) + SUBJECT_AGE = ("subject age", False, False) + SUBJECT_AGE_YD = ("subject age (y/d)", False, False) + SUBJECT_HAS_AN_OPEN_EPISODE = ("subject has an open episode", False, False) + SUBJECT_HAS_DIAGNOSTIC_TESTS = ("subject has diagnostic tests", False, False) + SUBJECT_HAS_EPISODES = ("subject has episodes", False, False) + SUBJECT_HAS_EVENT_STATUS = ("subject has event status", False, False) + SUBJECT_DOES_NOT_HAVE_EVENT_STATUS = ( + "subject does not have event status", + False, + False, + ) + SUBJECT_HAS_FOBT_EPISODES = ("subject has fobt episodes", False, False) + SUBJECT_HAS_LOGGED_FIT_KITS = ("subject has logged fit kits", False, False) + SUBJECT_HAS_UNLOGGED_KITS = ("subject has unlogged kits", False, False) + SUBJECT_HAS_UNPROCESSED_SSPI_UPDATES = ( + "subject has unprocessed sspi updates", + False, + False, + ) + SUBJECT_HAS_USER_DOB_UPDATES = ("subject has user dob updates", False, False) + SUBJECT_HAS_LYNCH_DIAGNOSIS = ("subject has lynch diagnosis", False, False) + SUBJECT_HAS_KIT_NOTES = ("subject has kit notes", False, False) + SUBJECT_HUB_CODE = ("subject hub code", True, False) + SUBJECT_LOWER_FOBT_AGE = ("subject lower fobt age", True, False) + SUBJECT_LOWER_LYNCH_AGE = ("subject lower lynch age", False, False) + SUBJECT_75TH_BIRTHDAY = ("subject's 75th birthday", False, False) + SURVEILLANCE_DUE_DATE_DATE_OF_CHANGE = ( + "surveillance due date date of change", + False, + False, + ) + SURVEILLANCE_DUE_DATE_REASON = ("surveillance due date reason", True, True) + SURVEILLANCE_DUE_DATE = ("surveillance due date", False, False) + SURVEILLANCE_REVIEW_CASE_TYPE = ("surveillance review case type", True, False) + SURVEILLANCE_REVIEW_STATUS = ("surveillance review status", True, True) + WHICH_APPOINTMENT = ("which appointment", False, True) + WHICH_DIAGNOSTIC_TEST = ("which diagnostic test", False, True) + WHICH_TEST_KIT = ("which test kit", False, True) + + def __init__( + self, + description: str, + allow_not_modifier: bool, + allow_more_than_one_value: bool, + ): + """ + Initialize a SubjectSelectionCriteriaKey enum member. + + Args: + description (str): The string description of the criteria key. + allow_not_modifier (bool): Whether the "not" modifier is allowed. + allow_more_than_one_value (bool): Whether more than one value is allowed. + """ + self._description = description + self._allow_not_modifier = allow_not_modifier + self._allow_more_than_one_value = allow_more_than_one_value + + @property + def description(self) -> str: + """ + Returns the string description of the criteria key. + + Returns: + str: The description. + """ + return self._description + + @property + def allow_not_modifier(self) -> bool: + """ + Returns True if the "not" modifier is allowed for this key. + + Returns: + bool: True if allowed, False otherwise. + """ + return self._allow_not_modifier + + @property + def allow_more_than_one_value(self) -> bool: + """ + Returns True if more than one value is allowed for this key. + + Returns: + bool: True if allowed, False otherwise. + """ + return self._allow_more_than_one_value + + @staticmethod + def by_description(description: str) -> Optional["SubjectSelectionCriteriaKey"]: + """ + Returns the enum member matching the given description. + + Args: + description (str): The description to search for. + + Returns: + Optional[SubjectSelectionCriteriaKey]: The matching enum member, or None if not found. + """ + return _description_map.get(description) + + +# Build description-to-enum map +_description_map: Dict[str, SubjectSelectionCriteriaKey] = { + key.description: key for key in SubjectSelectionCriteriaKey +} diff --git a/classes/surveillance_review_case_type.py b/classes/surveillance_review_case_type.py new file mode 100644 index 00000000..4df4da3e --- /dev/null +++ b/classes/surveillance_review_case_type.py @@ -0,0 +1,39 @@ +class SurveillanceReviewCaseType: + """ + Utility class for mapping surveillance review case type descriptions to valid value IDs. + + This class provides: + - A mapping from human-readable surveillance review case type descriptions (e.g., "routine", "escalation", "clinical discussion") to their corresponding internal valid value IDs. + - A method to retrieve the valid value ID for a given description. + + Methods: + get_id(description: str) -> int: + Returns the valid value ID for a given surveillance review case type description. + Raises ValueError if the description is not recognized. + """ + + _label_to_id = { + "routine": 9401, + "escalation": 9402, + "clinical discussion": 9403, + # Extend with additional mappings as needed + } + + @classmethod + def get_id(cls, description: str) -> int: + """ + Returns the valid value ID for a given surveillance review case type description. + + Args: + description (str): The surveillance review case type description. + + Returns: + int: The valid value ID corresponding to the description. + + Raises: + ValueError: If the description is not recognized. + """ + key = description.strip().lower() + if key not in cls._label_to_id: + raise ValueError(f"Unknown review case type: '{description}'") + return cls._label_to_id[key] diff --git a/classes/surveillance_review_status_type.py b/classes/surveillance_review_status_type.py new file mode 100644 index 00000000..3b3f5656 --- /dev/null +++ b/classes/surveillance_review_status_type.py @@ -0,0 +1,39 @@ +class SurveillanceReviewStatusType: + """ + Utility class for mapping surveillance review status descriptions to valid value IDs. + + This class provides: + - A mapping from human-readable surveillance review status descriptions (e.g., "awaiting review", "in progress", "completed") to their corresponding internal valid value IDs. + - A method to retrieve the valid value ID for a given description. + + Methods: + get_id(description: str) -> int: + Returns the valid value ID for a given surveillance review status description. + Raises ValueError if the description is not recognized. + """ + + _label_to_id = { + "awaiting review": 9301, + "in progress": 9302, + "completed": 9303, + # Extend if needed + } + + @classmethod + def get_id(cls, description: str) -> int: + """ + Returns the valid value ID for a given surveillance review status description. + + Args: + description (str): The surveillance review status description. + + Returns: + int: The valid value ID corresponding to the description. + + Raises: + ValueError: If the description is not recognized. + """ + key = description.strip().lower() + if key not in cls._label_to_id: + raise ValueError(f"Unknown surveillance review status: '{description}'") + return cls._label_to_id[key] diff --git a/classes/symptomatic_procedure_result_type.py b/classes/symptomatic_procedure_result_type.py new file mode 100644 index 00000000..11ef05d4 --- /dev/null +++ b/classes/symptomatic_procedure_result_type.py @@ -0,0 +1,39 @@ +class SymptomaticProcedureResultType: + """ + Utility class for mapping symptomatic surgery result descriptions to valid value IDs. + + This class provides: + - A mapping from human-readable symptomatic surgery result descriptions (e.g., "normal", "inconclusive", "cancer detected") to their corresponding internal valid value IDs. + - A method to retrieve the valid value ID for a given description. + + Methods: + get_id(description: str) -> int: + Returns the valid value ID for a given symptomatic procedure result description. + Raises ValueError if the description is not recognized. + """ + + _label_to_id = { + "normal": 9601, + "inconclusive": 9602, + "cancer detected": 9603, + # Add more as needed + } + + @classmethod + def get_id(cls, description: str) -> int: + """ + Returns the valid value ID for a given symptomatic procedure result description. + + Args: + description (str): The symptomatic procedure result description. + + Returns: + int: The valid value ID corresponding to the description. + + Raises: + ValueError: If the description is not recognized. + """ + key = description.strip().lower() + if key not in cls._label_to_id: + raise ValueError(f"Unknown symptomatic procedure result: '{description}'") + return cls._label_to_id[key] diff --git a/classes/user.py b/classes/user.py new file mode 100644 index 00000000..260b2a41 --- /dev/null +++ b/classes/user.py @@ -0,0 +1,135 @@ +from typing import Optional +from classes.organisation import Organisation + + +class User: + """ + Class representing a user in the system. + + Attributes: + user_id (Optional[int]): The unique identifier for the user. + role_id (Optional[int]): The role identifier for the user. + pio_id (Optional[int]): The PIO identifier for the user. + organisation (Optional[Organisation]): The organisation associated with the user. + + Methods: + __init__: Initializes a User instance. + user_id: Gets or sets the user ID. + role_id: Gets or sets the role ID. + pio_id: Gets or sets the PIO ID. + organisation: Gets or sets the organisation. + __str__: Returns a string representation of the User. + """ + + def __init__( + self, + user_id: Optional[int] = None, + role_id: Optional[int] = None, + pio_id: Optional[int] = None, + organisation: Optional[Organisation] = None, + ): + """ + Initialize a User instance. + + Args: + user_id (Optional[int]): The unique identifier for the user. + role_id (Optional[int]): The role identifier for the user. + pio_id (Optional[int]): The PIO identifier for the user. + organisation (Optional[Organisation]): The organisation associated with the user. + """ + self._user_id = user_id + self._role_id = role_id + self._pio_id = pio_id + self._organisation = organisation + + @property + def user_id(self) -> Optional[int]: + """ + Gets the user ID. + + Returns: + Optional[int]: The user ID. + """ + return self._user_id + + @user_id.setter + def user_id(self, value: int) -> None: + """ + Sets the user ID. + + Args: + value (int): The user ID to set. + """ + self._user_id = value + + @property + def role_id(self) -> Optional[int]: + """ + Gets the role ID. + + Returns: + Optional[int]: The role ID. + """ + return self._role_id + + @role_id.setter + def role_id(self, value: int) -> None: + """ + Sets the role ID. + + Args: + value (int): The role ID to set. + """ + self._role_id = value + + @property + def pio_id(self) -> Optional[int]: + """ + Gets the PIO ID. + + Returns: + Optional[int]: The PIO ID. + """ + return self._pio_id + + @pio_id.setter + def pio_id(self, value: int) -> None: + """ + Sets the PIO ID. + + Args: + value (int): The PIO ID to set. + """ + self._pio_id = value + + @property + def organisation(self) -> Optional[Organisation]: + """ + Gets the organisation. + + Returns: + Optional[Organisation]: The organisation associated with the user. + """ + return self._organisation + + @organisation.setter + def organisation(self, value: Organisation) -> None: + """ + Sets the organisation. + + Args: + value (Organisation): The organisation to set. + """ + self._organisation = value + + def __str__(self) -> str: + """ + Returns a string representation of the User. + + Returns: + str: The string representation of the user. + """ + org_id = ( + self.organisation.get_organisation_id() if self.organisation else "None" + ) + return f"User [userId={self.user_id}, orgId={org_id}, roleId={self.role_id}]" diff --git a/classes/which_diagnostic_test.py b/classes/which_diagnostic_test.py new file mode 100644 index 00000000..65bc9788 --- /dev/null +++ b/classes/which_diagnostic_test.py @@ -0,0 +1,64 @@ +class WhichDiagnosticTest: + """ + Maps descriptive diagnostic test selection types to internal constants. + Used to determine join and filter behavior in the query builder. + + Members: + ANY_TEST_IN_ANY_EPISODE: Any test in any episode. + ANY_TEST_IN_LATEST_EPISODE: Any test in the latest episode. + ONLY_TEST_IN_LATEST_EPISODE: Only test in the latest episode. + ONLY_NOT_VOID_TEST_IN_LATEST_EPISODE: Only not void test in the latest episode. + LATEST_TEST_IN_LATEST_EPISODE: Latest test in the latest episode. + LATEST_NOT_VOID_TEST_IN_LATEST_EPISODE: Latest not void test in the latest episode. + EARLIEST_NOT_VOID_TEST_IN_LATEST_EPISODE: Earliest not void test in the latest episode. + EARLIER_TEST_IN_LATEST_EPISODE: Earlier test in the latest episode. + LATER_TEST_IN_LATEST_EPISODE: Later test in the latest episode. + + Methods: + from_description(description: str) -> str: + Returns the internal constant for a given description. + Raises ValueError if the description is not recognized. + """ + + ANY_TEST_IN_ANY_EPISODE = "any_test_in_any_episode" + ANY_TEST_IN_LATEST_EPISODE = "any_test_in_latest_episode" + ONLY_TEST_IN_LATEST_EPISODE = "only_test_in_latest_episode" + ONLY_NOT_VOID_TEST_IN_LATEST_EPISODE = "only_not_void_test_in_latest_episode" + LATEST_TEST_IN_LATEST_EPISODE = "latest_test_in_latest_episode" + LATEST_NOT_VOID_TEST_IN_LATEST_EPISODE = "latest_not_void_test_in_latest_episode" + EARLIEST_NOT_VOID_TEST_IN_LATEST_EPISODE = ( + "earliest_not_void_test_in_latest_episode" + ) + EARLIER_TEST_IN_LATEST_EPISODE = "earlier_test_in_latest_episode" + LATER_TEST_IN_LATEST_EPISODE = "later_test_in_latest_episode" + + _valid_values = { + ANY_TEST_IN_ANY_EPISODE, + ANY_TEST_IN_LATEST_EPISODE, + ONLY_TEST_IN_LATEST_EPISODE, + ONLY_NOT_VOID_TEST_IN_LATEST_EPISODE, + LATEST_TEST_IN_LATEST_EPISODE, + LATEST_NOT_VOID_TEST_IN_LATEST_EPISODE, + EARLIEST_NOT_VOID_TEST_IN_LATEST_EPISODE, + EARLIER_TEST_IN_LATEST_EPISODE, + LATER_TEST_IN_LATEST_EPISODE, + } + + @classmethod + def from_description(cls, description: str) -> str: + """ + Returns the internal constant for a given description. + + Args: + description (str): The description to look up. + + Returns: + str: The internal constant matching the description. + + Raises: + ValueError: If the description is not recognized. + """ + key = description.strip().lower() + if key not in cls._valid_values: + raise ValueError(f"Unknown diagnostic test selection: '{description}'") + return key diff --git a/classes/yes_no_type.py b/classes/yes_no_type.py new file mode 100644 index 00000000..86437eb1 --- /dev/null +++ b/classes/yes_no_type.py @@ -0,0 +1,37 @@ +class YesNoType: + """ + Utility class for handling 'yes'/'no' type values. + + Members: + YES: Represents a 'yes' value. + NO: Represents a 'no' value. + + Methods: + from_description(description: str) -> str: + Returns the normalized 'yes' or 'no' value for a given description. + Raises ValueError if the description is not recognized. + """ + + YES = "yes" + NO = "no" + + _valid = {YES, NO} + + @classmethod + def from_description(cls, description: str) -> str: + """ + Returns the normalized 'yes' or 'no' value for a given description. + + Args: + description (str): The input description to normalize. + + Returns: + str: 'yes' or 'no'. + + Raises: + ValueError: If the description is not recognized as 'yes' or 'no'. + """ + key = description.strip().lower() + if key not in cls._valid: + raise ValueError(f"Expected 'yes' or 'no', got: '{description}'") + return key diff --git a/conftest.py b/conftest.py index 0972c8f3..754d110f 100644 --- a/conftest.py +++ b/conftest.py @@ -11,6 +11,7 @@ from pathlib import Path from _pytest.python import Function from pytest_html.report_data import ReportData +from utils.load_properties_file import PropertiesFile # Environment Variable Handling @@ -31,11 +32,21 @@ def import_local_env_file() -> None: load_dotenv(LOCAL_ENV_PATH, override=False) +@pytest.fixture +def smokescreen_properties() -> dict: + return PropertiesFile().get_smokescreen_properties() + + +@pytest.fixture +def general_properties() -> dict: + return PropertiesFile().get_general_properties() + + # HTML Report Customization def pytest_html_report_title(report: ReportData) -> None: - report.title = "Test Automation Report" + report.title = "BCSS Test Automation Report" def pytest_html_results_table_header(cells: list) -> None: @@ -53,6 +64,3 @@ def pytest_runtest_makereport(item: Function) -> typing.Generator[None, None, No if outcome is not None: report = outcome.get_result() report.description = str(item.function.__doc__) - - -### Add your additional fixtures or hooks below ### diff --git a/docs/utility-guides/BatchProcessing.md b/docs/utility-guides/BatchProcessing.md new file mode 100644 index 00000000..fba8161b --- /dev/null +++ b/docs/utility-guides/BatchProcessing.md @@ -0,0 +1,138 @@ +# Utility Guide: Batch Processing + +The Batch Processing utility provides a one-stop function for processing batches on the active batch list page, streamlining all necessary steps into a single call. **To process a batch, call the `batch_processing` function as described below.** + +## Table of Contents + +- [Utility Guide: Batch Processing](#utility-guide-batch-processing) + - [Table of Contents](#table-of-contents) + - [Example Usage](#example-usage) + - [Functions Overview](#functions-overview) + - [Batch Processing](#batch-processing) + - [Required Arguments](#required-arguments) + - [Optional Arguments](#optional-arguments) + - [How This Function Works](#how-this-function-works) + - [Prepare And Print Batch](#prepare-and-print-batch) + - [Arguments](#arguments) + - [Optional Arguments](#optional-arguments-1) + - [How This Function Works](#how-this-function-works-1) + - [Check Batch In Archived Batch List](#check-batch-in-archived-batch-list) + - [Arguments](#arguments-1) + - [How This Function Works](#how-this-function-works-2) + +## Example Usage + +```python +from utils.batch_processing import batch_processing + +batch_processing( + page=page, + batch_type="S1", + batch_description="Pre-invitation (FIT)", + latest_event_status=["Status1", "Status2"], # Can be str or list[str] + run_timed_events=True, + get_subjects_from_pdf=False +) +``` + +## Functions Overview + +For this utility we have the following functions: + +- `batch_processing` +- `prepare_and_print_batch` +- `check_batch_in_archived_batch_list` + +### Batch Processing + +This is the **main entry point function** that should be called to process a batch. It manages and coordinates all the required steps by internally calling the other two functions and auxiliary utilities as needed. + +#### Required Arguments + +- `page`: + - Type: `Page` + - This is the playwright page object which is used to tell playwright what page the test is currently on. +- `batch_type`: + - Type: `str` + - This is the event code for the batch. For example: **S1** or **A323** +- `batch_description`: + - Type: `str` + - This is the description of the batch. For example: **Pre-invitation (FIT)** or **Post-investigation Appointment NOT Required** +- `latest_event_status`: + - Type: `str | list[str] |` + - This is the status or list of statuses the subject(s) will get updated to after the batch has been processed. It is used to check that the subject(s) have been updated to the correct status after a batch has been printed. + +#### Optional Arguments + +- `run_timed_events`: + - Type: `bool` + - If this is set to **True**, then bcss_timed_events will be executed against all the subjects found in the batch + - These timed events simulate the passage of time-dependent processing steps. +- `get_subjects_from_pdf`: + - Type: `bool` + - If this is set to **True**, then the subjects will be retrieved from the downloaded PDF file instead of from the DB + +#### How This Function Works + +1. It starts off by navigating to the main menu if not already on this page. This is done to ensure that this can be called from any page +2. Once on the main menu it navigates to the active batch list +3. From here it fills in the search filters to narrow down the list of active batches to only those which match the arguments provided +4. Once only the expected batches are shown it checks the status column of the records + 1. If *Prepared* is found then it ignores it, otherwise if *Open* is found then it carries on +5. Now it extracts the ID of the batch and stores it in the local variable `link_text`, this is used later on to extracts the subjects in the batch from the DB +6. After the ID is stored, it clicks on the ID to get to the Manage Active Batch page +7. From Here it calls the `prepare_and_print_batch` function. + 1. If `get_subjects_from_pdf` was set to False it calls `get_nhs_no_from_batch_id`, which is imported from *utils.oracle.oracle_specific_functions*, to get the subjects from the DB and stores them as a pandas DataFrame - **nhs_no_df** +8. Once this is complete it calls the `check_batch_in_archived_batch_list` function +9. Finally, once that function is complete it calls `verify_subject_event_status_by_nhs_no` which is imported from *utils/screening_subject_page_searcher* + +### Prepare And Print Batch + +This is used when on the Manage Active Batch page. +It is in charge of pressing on the following button: **Prepare Batch**, **Retrieve** and **Confirm Printed** + +#### Arguments + +- `page`: + - Type: `Page` + - This is the playwright page object which is used to tell playwright what page the test is currently on. +- `link_text`: + - Type: `str` + - This is the batch ID of the batch currently being processed + +#### Optional Arguments + +- `get_subjects_from_pdf`: + - Type: `bool` + - If this is set to **True**, then the subjects will be retrieved from the downloaded PDF file instead of from the DB + +#### How This Function Works + +1. It starts off by clicking on the **Prepare Batch** button. +2. After this it waits for the button to turn into **Re-Prepare Batch**. Once this happens it means that the batch is ready to be printed. +3. Now It clicks on each **Retrieve** button visible. + 1. If `get_subjects_from_pdf` was set to True and the file is a **.pdf**, then it calls `extract_nhs_no_from_pdf`, which is imported from *utils/pdf_reader*, to get the subjects from the PDF and stores them as a pandas DataFrame - **nhs_no_df** + 2. For more Info on `extract_nhs_no_from_pdf` please look at: [`PDFReader`](PDFReader.md) + 3. After a file is downloaded, it gets deleted. +4. Then it clicks on each **Confirm Printed** button ensuring to handle the dialog that appears. +5. Finally it checks for the message: *Batch Successfully Archived and Printed* + +### Check Batch In Archived Batch List + +This function checks that the batch that was just prepared and printed is now visible in the archived batch list + +#### Arguments + +- `page`: + - Type: `Page` + - This is the playwright page object which is used to tell playwright what page the test is currently on. +- `link_text`: + - Type: `str` + - This is the batch ID of the batch currently being processed + +#### How This Function Works + +1. This starts off by navigating to the main menu. +2. From here it goes to the archived batch list page. +3. Once on the archived batch list page, it enters `link_text` into the ID filter. +4. Finally it checks that the batch is visible in the table. diff --git a/docs/utility-guides/CalendarPicker.md b/docs/utility-guides/CalendarPicker.md new file mode 100644 index 00000000..13aa6983 --- /dev/null +++ b/docs/utility-guides/CalendarPicker.md @@ -0,0 +1,159 @@ +# Utility Guide: Calendar Picker + +The Calendar Picker utility provides functions for interacting with the different calendar pickers found throughout BCSS.
+**On BCSS there are three different calendar types:** + +1. **V1 Calendar Picker:** + Seen on pages like the Screening Subject Search page. This calendar allows users to change the year/month in increments of 1 using the `<<`, `<`, `>`, `>>` buttons. + +2. **V2 Calendar Picker:** + Seen on pages like the Active Batch List page. This calendar allows users to expand the view of dates for faster navigation to previous/future dates. + +3. **Appointments Calendar:** + Seen on appointment booking pages. This consists of two calendars side by side, with cell background colours indicating appointment availability. Navigation is also via `<<`, `<`, `>`, `>>` buttons. + +**You must use the applicable function for the calendar type you are interacting with.** + +## Table of Contents + +- [Utility Guide: Calendar Picker](#utility-guide-calendar-picker) + - [Table of Contents](#table-of-contents) + - [Summary of Calendar Types](#summary-of-calendar-types) + - [Main Methods](#main-methods) + - [`calendar_picker_ddmmyyyy` (V1 Calendar Picker)](#calendar_picker_ddmmyyyy-v1-calendar-picker) + - [`calendar_picker_ddmonyy` (V2 Calendar Picker)](#calendar_picker_ddmonyy-v2-calendar-picker) + - [`v1_calendar_picker` (V1 Calendar Picker)](#v1_calendar_picker-v1-calendar-picker) + - [`v2_calendar_picker` (V2 Calendar Picker)](#v2_calendar_picker-v2-calendar-picker) + - [`book_first_eligible_appointment` (Appointments Calendar)](#book_first_eligible_appointment-appointments-calendar) + - [Supporting Methods](#supporting-methods) + - [Example Usage](#example-usage) + +--- + +## Summary of Calendar Types + +| Calendar Type | Where Seen | Navigation Style | Main Method(s) to Use | +|------------------------ |-----------------------------------|---------------------------------------------------|--------------------------------------| +| V1 Calendar Picker | Screening Subject Search, others | Year/month navigation with `<<`, `<`, `>`, `>>` | `calendar_picker_ddmmyyyy`, `v1_calendar_picker` | +| V2 Calendar Picker | Active Batch List, others | Expandable view for fast navigation | `calendar_picker_ddmonyy`, `v2_calendar_picker` | +| Appointments Calendar | Appointment booking pages | Two calendars, cell colours for availability | `book_first_eligible_appointment` | + +--- + +## Main Methods + +### `calendar_picker_ddmmyyyy` (V1 Calendar Picker) + +Inputs a date as a string in the format `dd/mm/yyyy` (e.g. 16/01/2025) into a field, instead of using the picker UI. + +**Arguments:** + +- `date` (`datetime`): The date you want to enter. +- `locator` (`Locator`): The Playwright locator for the field. + +**How it works:** +Formats the date using DateTimeUtils and enters it directly into the field. + +--- + +### `calendar_picker_ddmonyy` (V2 Calendar Picker) + +Inputs a date as a string in the format `dd mon yy` (e.g. 16 Jan 25) into a field, instead of using the picker UI. + +**Arguments:** + +- `date` (`datetime`): The date you want to enter. +- `locator` (`Locator`): The Playwright locator for the field. + +**How it works:** +Formats the date using DateTimeUtils (with OS-specific handling) and enters it directly into the field. + +--- + +### `v1_calendar_picker` (V1 Calendar Picker) + +Uses the navigation buttons (`<<`, `<`, `>`, `>>`) to select a date in the V1 calendar picker. + +**Arguments:** + +- `date` (`datetime`): The date you want to select. + +**How it works:** +Determines how many years/months to traverse, navigates using the appropriate buttons, and selects the day. + +--- + +### `v2_calendar_picker` (V2 Calendar Picker) + +Uses the navigation controls to select a date in the V2 calendar picker, which allows for faster navigation by expanding the view. + +**Arguments:** + +- `date` (`datetime`): The date you want to select. + +**How it works:** +Calculates the required navigation steps, expands the picker view as needed, and selects the desired date. + +--- + +### `book_first_eligible_appointment` (Appointments Calendar) + +Selects the first day with available appointment slots in the appointments calendar (which shows two months side by side). + +**Arguments:** + +- `current_month_displayed` (`str`): The current month displayed by the calendar. +- `locator` (`Locator`): The locator for the appointment day cells. +- `bg_colours` (`list`): List of background colours indicating available slots. + +**How it works:** +Navigates through months and selects the first available appointment date based on cell background colour. + +--- + +## Supporting Methods + +These methods are used internally by the main methods above: + +- `calculate_years_and_months_to_traverse` +- `traverse_years_in_v1_calendar` +- `traverse_months_in_v1_calendar` +- `calculate_v2_calendar_variables` +- `v2_calendar_picker_traverse_back` +- `v2_calendar_picker_traverse_forward` +- `select_day` +- `book_appointments_go_to_month` +- `check_for_eligible_appointment_dates` + +--- + +## Example Usage + +```python +from utils.calendar_picker import CalendarPicker +from datetime import datetime + +# Example 1: Input a date as a string for V1 calendar picker +CalendarPicker(page).calendar_picker_ddmmyyyy(datetime(2025, 1, 16), page.locator("#date-input")) + +# Example 2: Input a date as a string for V2 calendar picker +CalendarPicker(page).calendar_picker_ddmonyy(datetime(2025, 1, 16), page.locator("#date-input")) + +# Example 3: Use the V1 calendar picker to select a date +CalendarPicker(page).v1_calendar_picker(datetime(2025, 1, 16)) + +# Example 4: Use the V2 calendar picker to select a date +CalendarPicker(page).v2_calendar_picker(datetime(2025, 1, 16)) + +# Example 5: Book the first eligible appointment in the appointments calendar +# In this example, we use hard-coded variables. In our actual tests, these values are obtained from Page Object Models (POMs). See [C4] for a more practical example. +CalendarPicker(page).book_first_eligible_appointment( + current_month_displayed="January", + locator=page.locator(".appointment-day"), + bg_colours=["#00FF00", "#99FF99"] # Example colours for available slots +) +``` + +> **Tip:** Always use the function that matches the calendar type you are interacting with in BCSS. + +For more details on each function's implementation, refer to the source code in `utils/calendar_picker.py`. diff --git a/docs/utility-guides/DatasetField.md b/docs/utility-guides/DatasetField.md new file mode 100644 index 00000000..d249c0f8 --- /dev/null +++ b/docs/utility-guides/DatasetField.md @@ -0,0 +1,87 @@ +# Utility Guide: Dataset Field Utility + +The Dataset Field utility allows for selecting different locators dynamically.
+For example, on the investigation dataset, if we want to select the input locator for the `Start of intubation time` field, we can do this by providing the utility with the name of the field + + DatasetFieldUtil(page).populate_input_locator_for_field( + "Start of intubation time", "09:00" + ) + +## Table of Contents + +- [Utility Guide: Dataset Field Utility](#utility-guide-dataset-field-utility) + - [Table of Contents](#table-of-contents) + - [Using the DatasetFieldUtil class](#using-the-datasetfieldutil-class) + - [Required Args](#required-args) + - [How to use this method](#how-to-use-this-method) + +## Using the DatasetFieldUtil class + +You can initialise the DatasetFieldUtil class by using the following code in your test file: + + from utils.dataset_field_util import DatasetFieldUtil + +This will allow you to use the following methods: + +1. populate_input_locator_for_field + 1. This will allow you to populate the field next to the given text where the type is `input` +2. populate_select_locator_for_field + 1. This will allow you to populate the field next to the given text where the type is `select` +3. populate_input_locator_for_field_inside_div + 1. This will allow you to populate the field next to the given text where the type is `input`, and inside of a specified container +4. populate_select_locator_for_field_inside_div + 1. This will allow you to populate the field next to the given text where the type is `select`, and inside of a specified container + +### Required Args + +`populate_input_locator_for_field` / `populate_select_locator_for_field` + +- text: + - Type: `str` + - The text of the element you want to interact with. +- value/option: + - Type: `str` + - The value or option you want to input / select (depending on what method is called) + +`populate_input_locator_for_field_inside_div` / `populate_select_locator_for_field_inside_div` + +- text: + - Type: `str` + - The text of the element you want to interact with. +- div: + - Type: `str` + - The ID of the container that the element belongs in. +- value/option: + - Type: `str` + - The value or option you want to input / select (depending on what method is called) + +### How to use this method + +To use this method simply import the DatasetFieldUtil class and call one of the methods, providing the necessary arguments.
+The example below is using options that can be imported from `pages.datasets.investigation_dataset_page` + + from utils.dataset_field_util import DatasetFieldUtil + from pages.datasets.investigation_dataset_page import ( + InsufflationOptions, + PolypClassificationOptions, + ) + + # populate_input_locator_for_field + DatasetFieldUtil(page).populate_input_locator_for_field( + "End time of procedure", "09:30" + ) + + # populate_select_locator_for_field + DatasetFieldUtil(page).populate_select_locator_for_field( + "Insufflation", InsufflationOptions.AIR + ) + + # populate_input_locator_for_field_inside_div + DatasetFieldUtil(page).populate_input_locator_for_field_inside_div( + "Estimate of whole polyp size", "divPolypNumber1Section", "15" + ) + + # populate_select_locator_for_field_inside_div + DatasetFieldUtil(page).populate_select_locator_for_field_inside_div( + "Classification", "divPolypNumber1Section", PolypClassificationOptions.LS + ) diff --git a/docs/utility-guides/DateTimeUtility.md b/docs/utility-guides/DateTimeUtility.md index 93eef484..9aedc5bf 100644 --- a/docs/utility-guides/DateTimeUtility.md +++ b/docs/utility-guides/DateTimeUtility.md @@ -1,22 +1,131 @@ # Utility Guide: Date Time Utility -The Date Time Utility can be used to manipulate dates and times for various purposes, -such as asserting timestamp values, changing the format of a date, or returning the day of the week for a given date. +The Date Time Utility provides a set of helper functions for manipulating dates and times in your tests. +You can use it to assert timestamp values, change the format of a date, calculate date differences, add or subtract time, and more. + +## Table of Contents + +- [Utility Guide: Date Time Utility](#utility-guide-date-time-utility) + - [Table of Contents](#table-of-contents) + - [Using the Date Time Utility](#using-the-date-time-utility) + - [Main Features](#main-features) + - [Example Usage](#example-usage) + - [Method Reference](#method-reference) + - [`current_datetime(format_date: str = "%d/%m/%Y %H:%M") -> str`](#current_datetimeformat_date-str--dmy-hm---str) + - [`format_date(date: datetime, format_date: str = "%d/%m/%Y") -> str`](#format_datedate-datetime-format_date-str--dmy---str) + - [`add_days(date: datetime, days: float) -> datetime`](#add_daysdate-datetime-days-float---datetime) + - [`get_day_of_week_for_today(date: datetime) -> str`](#get_day_of_week_for_todaydate-datetime---str) + - [`get_a_day_of_week(date: datetime) -> str`](#get_a_day_of_weekdate-datetime---str) + - [`report_timestamp_date_format() -> str`](#report_timestamp_date_format---str) + - [`fobt_kits_logged_but_not_read_report_timestamp_date_format() -> str`](#fobt_kits_logged_but_not_read_report_timestamp_date_format---str) + - [`screening_practitioner_appointments_report_timestamp_date_format() -> str`](#screening_practitioner_appointments_report_timestamp_date_format---str) + - [`month_string_to_number(string: str) -> int`](#month_string_to_numberstring-str---int) ## Using the Date Time Utility -To use the Date Time Utility, import the 'DateTimeUtils' class into your test file and then call the DateTimeUtils -functions from within your tests, as required. +To use the Date Time Utility, import the `DateTimeUtils` class from `utils.date_time_utils` into your test file and call its methods as needed. + +```python +from utils.date_time_utils import DateTimeUtils +``` + +## Main Features + +- Get the current date/time in various formats +- Format dates to different string representations +- Add or subtract days from a date +- Get the day of the week for a given date +- Get formatted timestamps for different report types +- Convert a month name string to its corresponding number + +--- + +## Example Usage + +```python +from utils.date_time_utils import DateTimeUtils +from datetime import datetime + +# Get the current date and time as a string +now_str = DateTimeUtils.current_datetime() +print(now_str) # e.g. '28/05/2025 14:30' + +# Format a datetime object as 'dd/mm/yyyy' +formatted = DateTimeUtils.format_date(datetime(2025, 1, 16)) +print(formatted) # '16/01/2025' + +# Add 5 days to a date +future_date = DateTimeUtils.add_days(datetime(2025, 1, 16), 5) +print(future_date) # datetime object for 21/01/2025 + +# Get the day of the week for a date +weekday = DateTimeUtils.get_day_of_week_for_today(datetime(2025, 1, 16)) +print(weekday) # 'Thursday' + +# Get the day of the week (alias method) +weekday2 = DateTimeUtils.get_a_day_of_week(datetime(2025, 1, 16)) +print(weekday2) # 'Thursday' + +# Get the current timestamp in report format +report_timestamp = DateTimeUtils.report_timestamp_date_format() +print(report_timestamp) # e.g. '28/05/2025 at 14:30:00' + +# Get the current timestamp in FOBT kits report format +fobt_timestamp = DateTimeUtils.fobt_kits_logged_but_not_read_report_timestamp_date_format() +print(fobt_timestamp) # e.g. '28 May 2025 14:30:00' + +# Get the current timestamp in screening practitioner appointments report format +spa_timestamp = DateTimeUtils.screening_practitioner_appointments_report_timestamp_date_format() +print(spa_timestamp) # e.g. '28.05.2025 at 14:30:00' + +# Convert a month name to its number +month_num = DateTimeUtils().month_string_to_number("February") +print(month_num) # 2 + +month_num_short = DateTimeUtils().month_string_to_number("Sep") +print(month_num_short) # 9 +``` + +--- + +## Method Reference + +### `current_datetime(format_date: str = "%d/%m/%Y %H:%M") -> str` + +Returns the current date as a string in the specified format. + +### `format_date(date: datetime, format_date: str = "%d/%m/%Y") -> str` + +Formats a given `datetime` object as a string. + +### `add_days(date: datetime, days: float) -> datetime` + +Adds a specified number of days to a given date. + +### `get_day_of_week_for_today(date: datetime) -> str` + +Returns the day of the week for the given date. + +### `get_a_day_of_week(date: datetime) -> str` + +Alias for `get_day_of_week_for_today`. + +### `report_timestamp_date_format() -> str` + +Returns the current date and time in the format `'dd/mm/yyyy at hh:mm:ss'`. + +### `fobt_kits_logged_but_not_read_report_timestamp_date_format() -> str` + +Returns the current date and time in the format `'dd Mon yyyy hh:mm:ss'`. + +### `screening_practitioner_appointments_report_timestamp_date_format() -> str` -## Required arguments +Returns the current date and time in the format `'dd.mm.yyyy at hh:mm:ss'`. -The functions in this class require different arguments, including `datetime`, `str`, and `float`. -Look at the docstrings for each function to see what arguments are required. -The docstrings also specify when arguments are optional, and what the default values are when no argument is provided. +### `month_string_to_number(string: str) -> int` -## Example usage +Converts a month name (full or short, case-insensitive) to its corresponding number (1-12). - from tests_utils.date_time_utils import DateTimeUtils +--- - def test_date_format(page: Page) -> None: - expect(page.locator("#date")).to_contain_text(DateTimeUtils.current_datetime()) +Refer to the source code and function docstrings in `utils/date_time_utils.py` for more details and any additional helper methods. diff --git a/docs/utility-guides/FitKit.md b/docs/utility-guides/FitKit.md new file mode 100644 index 00000000..9738e5a1 --- /dev/null +++ b/docs/utility-guides/FitKit.md @@ -0,0 +1,152 @@ +# Utility Guide: Fit Kit Utility + +This guide covers both the Fit Kit Generation and Fit Kit Logged utilities, which together provide methods to generate, manage, and process FIT test kits for testing purposes. + +## Table of Contents + +- [Utility Guide: Fit Kit Utility](#utility-guide-fit-kit-utility) + - [Table of Contents](#table-of-contents) + - [Fit Kit Generation Utility](#fit-kit-generation-utility) + - [Overview](#overview) + - [How to Use](#how-to-use) + - [Required Arguments](#required-arguments) + - [Key Methods](#key-methods) + - [Example Usage](#example-usage) + - [Fit Kit Logged Utility](#fit-kit-logged-utility) + - [Overview](#overview-1) + - [How to Use](#how-to-use-1) + - [Required Arguments](#required-arguments-1) + - [Key Methods](#key-methods-1) + - [Example Usage](#example-usage-1) + - [1st Example - Basic](#1st-example---basic) + - [2nd Example - Compartment 3](#2nd-example---compartment-3) + +--- + +## Fit Kit Generation Utility + +### Overview + +The Fit Kit Generation Utility (`FitKitGeneration` class) provides methods to generate and manage FIT test kits for testing purposes. It retrieves kit IDs from the database, calculates check digits, and formats them as FIT Device IDs ready for use in tests. + +### How to Use + +Import the `FitKitGeneration` class from `utils/fit_kit.py` and use its methods to generate and process FIT kit IDs. + +```python +from utils.fit_kit import FitKitGeneration +``` + +### Required Arguments + +- `create_fit_id_df`: Requires `tk_type_id` (int), `hub_id` (int), and `no_of_kits_to_retrieve` (int). + +### Key Methods + +1. **`create_fit_id_df(tk_type_id: int, hub_id: int, no_of_kits_to_retrieve: int) -> pd.DataFrame`** + - Retrieves kit IDs from the database, calculates check digits, and generates FIT Device IDs. + - **Returns:** A pandas DataFrame with the processed FIT Device IDs. + +2. **`calculate_check_digit(kit_id: str) -> str`** + - Calculates and appends a check digit to the given kit ID. + +3. **`convert_kit_id_to_fit_device_id(kit_id: str) -> str`** + - Converts a kit ID into a FIT Device ID by appending an expiry date and a fixed suffix. + +> **Tip:** +> To obtain a pandas DataFrame containing a list of FIT Kits to use, you only need to call the `create_fit_id_df` method. This method internally calls the other two methods to generate the correct check digits and expiration tags. + +### Example Usage + +```python +from utils.fit_kit import FitKitGeneration + +def example_usage() -> None: + tk_type_id = 2 + hub_id = 101 + no_of_kits_to_retrieve = 2 + + fit_kit_gen = FitKitGeneration() + fit_kit_df = fit_kit_gen.create_fit_id_df(tk_type_id, hub_id, no_of_kits_to_retrieve) + print("Processed FIT Kit DataFrame:") + print(fit_kit_df) +``` + +--- + +## Fit Kit Logged Utility + +### Overview + +The Fit Kit Logged Utility (`FitKitLogged` class) provides methods to retrieve test data (fit kit test results) used by the compartment 3 tests, and splits them into two dataframes: one for 'normal' results and one for 'abnormal' results. + +### How to Use + +Import the `FitKitLogged` class from `utils/fit_kit.py` and use its methods to process and split FIT kit data. + +```python +from utils.fit_kit import FitKitLogged +``` + +### Required Arguments + +- `process_kit_data`: Requires `smokescreen_properties` (dict) +- `split_fit_kits`: Requires `kit_id_df` (pd.DataFrame), `smokescreen_properties` (dict) + +### Key Methods + +1. **`process_kit_data(smokescreen_properties: dict) -> list`** + - Retrieves test data for compartment 3 and splits it into normal and abnormal kits using `split_fit_kits`. + - **Returns:** A list of tuples, each containing a device ID (str) and a `boolean` flag (`True` for normal, `False` for abnormal). + +2. **`split_fit_kits(kit_id_df: pd.DataFrame, smokescreen_properties: dict) -> tuple[pd.DataFrame, pd.DataFrame]`** + - Splits the DataFrame into two: one for normal kits and one for abnormal kits, based on the numbers specified in `smokescreen_properties`. + - **Returns:** A tuple containing two DataFrames: `(normal_fit_kit_df, abnormal_fit_kit_df)`. + +> **How it works:** +> +> - The number of normal and abnormal kits is determined by the `c3_eng_number_of_normal_fit_kits` and `c3_eng_number_of_abnormal_fit_kits` keys in the `smokescreen_properties` dictionary. +> - The utility retrieves the required number of kits, splits them, and returns device IDs with their normal/abnormal status. + +### Example Usage + +#### 1st Example - Basic + +This example is showing how the utility can be used to get a pandas `dataframe` containing a list of normal / abnormal kits. + +```python +from utils.fit_kit import FitKitLogged + +def test_example_usage(smokescreen_properties: dict) -> None: + fit_kit_logged = FitKitLogged() + # Retrieve and split FIT kit data + device_ids = fit_kit_logged.process_kit_data(smokescreen_properties) + # Example: process device IDs and their normal/abnormal status + for device_id, is_normal in device_ids: + print(f"Device ID: {device_id}, Normal: {is_normal}") +``` + +#### 2nd Example - Compartment 3 + +This example is showing how we are using this utility in compartment 3. + +```python +from utils.fit_kit import FitKitLogged +from utils.oracle.oracle_specific_functions import update_kit_service_management_entity + +def test_compartment_3(page: Page, smokescreen_properties: dict): + device_ids = FitKitLogged().process_kit_data(smokescreen_properties) + nhs_numbers = [] + normal_flags = [] + + for device_id, is_normal in device_ids: + nhs_number = update_kit_service_management_entity( + device_id, is_normal, smokescreen_properties + ) + nhs_numbers.append(nhs_number) + normal_flags.append(is_normal) +``` + +--- + +For more details on each method's implementation, refer to the source code in `utils/fit_kit.py`. diff --git a/docs/utility-guides/InvestigationDataset.md b/docs/utility-guides/InvestigationDataset.md new file mode 100644 index 00000000..da75c9eb --- /dev/null +++ b/docs/utility-guides/InvestigationDataset.md @@ -0,0 +1,147 @@ +# Utility Guide: Investigation Dataset Utility + +The Investigation Dataset Utility provides methods to fill out the investigation dataset forms and progress episodes based on the age of the subject and the test results.
+**Note:** This utility uses predetermined variables and logic to select input field values and progress forms. There is currently no way to alter which options are selected for each field, results are determined by the utility's internal logic. + +## Table of Contents + +- [Utility Guide: Investigation Dataset Utility](#utility-guide-investigation-dataset-utility) + - [Table of Contents](#table-of-contents) + - [Using the Investigation Dataset Utility](#using-the-investigation-dataset-utility) + - [InvestigationDatasetCompletion Class](#investigationdatasetcompletion-class) + - [Required Arguments](#required-arguments) + - [Key Methods](#key-methods) + - [AfterInvestigationDatasetComplete Class](#afterinvestigationdatasetcomplete-class) + - [Required Arguments](#required-arguments-1) + - [Key Methods](#key-methods-1) + - [InvestigationDatasetResults Class](#investigationdatasetresults-class) + - [Example Usage](#example-usage) + +## Using the Investigation Dataset Utility + +To use the Investigation Dataset Utility, import the `InvestigationDatasetCompletion`, `AfterInvestigationDatasetComplete`, and `InvestigationDatasetResults` classes from the `utils.investigation_dataset` module into your test file and call the relevant methods as required. + +```python +from utils.investigation_dataset import ( + InvestigationDatasetCompletion, + AfterInvestigationDatasetComplete, + InvestigationDatasetResults, +) +``` + +The **InvestigationDatasetCompletion** and **AfterInvestigationDatasetComplete** classes are meant to be used one after another. this is because once the methods in the first class complete, you are on the correct page to call the methods in the other class. For further clarity on this please use the example provided at the bottom of the guide as a reference. + +--- + +## InvestigationDatasetCompletion Class + +This class is responsible for filling out the investigation dataset forms for a subject. +**All field selections are made using predetermined values within the utility.** + +### Required Arguments + +- `page (Page)`: The Playwright Page object used for browser automation. + +### Key Methods + +- **`complete_with_result(self, nhs_no: str, result: str) -> None`** + Fills out the investigation dataset forms based on the test result and the subject's age. + - `nhs_no (str)`: The NHS number of the subject. + - `result (str)`: The result of the investigation dataset. Should be one of `InvestigationDatasetResults` (`HIGH_RISK`, `LNPCP`, `NORMAL`). + +- **`go_to_investigation_datasets_page(self, nhs_no) -> None`** + Navigates to the investigation datasets page for a subject. + +- **`default_investigation_dataset_forms(self) -> None`** + Fills out the first part of the default investigation dataset form. + +- **`default_investigation_dataset_forms_continuation(self) -> None`** + Fills out the second part of the default investigation dataset form. + +- **`investigation_datasets_failure_reason(self) -> None`** + Fills out the failure reason section of the investigation dataset form. + +- **`polyps_for_high_risk_result(self) -> None`** + Fills out the polyp information section of the investigation dataset form to trigger a high risk result. + +- **`polyps_for_lnpcp_result(self) -> None`** + Fills out the polyp information section of the investigation dataset form to trigger a LNPCP result. + +- **`polyp1_intervention(self) -> None`** + Fills out the intervention section of the investigation dataset form for polyp 1. + +- **`save_investigation_dataset(self) -> None`** + Saves the investigation dataset form. + +--- + +## AfterInvestigationDatasetComplete Class + +This class provides methods to progress an episode after the investigation dataset has been completed. +**All field selections and progressions are made using predetermined values within the utility.** + +### Required Arguments + +- `page (Page)`: The Playwright Page object used for browser automation. + +- **For main progression method:** + - `result (str)`: The result of the investigation dataset. Should be one of `InvestigationDatasetResults` (`HIGH_RISK`, `LNPCP`, `NORMAL`). + - `younger (bool)`: `True` if the subject is between 50-70, `False` otherwise. + +### Key Methods + +- **`progress_episode_based_on_result(self, result: str, younger: bool) -> None`** + Progresses the episode according to the investigation result and whether the subject is younger than 70. + +- **`after_high_risk_result(self) -> None`** + Advances an episode that has a high-risk result using predetermined field values. + +- **`after_lnpcp_result(self) -> None`** + Advances an episode that has a LNPCP result using predetermined field values. + +- **`after_normal_result(self) -> None`** + Advances an episode that has a normal result using predetermined field values. + +- **`handover_subject_to_symptomatic_care(self) -> None`** + Hands over a subject to symptomatic care. + +- **`record_diagnosis_date(self) -> None`** + Records the diagnosis date for a subject. + +--- + +## InvestigationDatasetResults Class + +This `enum` provides the possible result values for the investigation dataset: + +- `HIGH_RISK` +- `LNPCP` +- `NORMAL` + +--- + +## Example Usage + +```python +from playwright.sync_api import Page +from utils.investigation_dataset import ( + InvestigationDatasetCompletion, + AfterInvestigationDatasetComplete, + InvestigationDatasetResults, +) + +nhs_no="1234567890", +result=InvestigationDatasetResults.HIGH_RISK, +younger=True + +investigation = InvestigationDatasetCompletion(page) +investigation.complete_with_result(nhs_no, result) + +# Progress the episode based on the result and subject's age +after_investigation = AfterInvestigationDatasetComplete(page) +after_investigation.progress_episode_based_on_result(result, younger) +``` + +--- + +For more details on each function's implementation, refer to the source code in `utils/investigation_dataset.py`. diff --git a/docs/utility-guides/LoadProperties.md b/docs/utility-guides/LoadProperties.md new file mode 100644 index 00000000..f7ade82b --- /dev/null +++ b/docs/utility-guides/LoadProperties.md @@ -0,0 +1,76 @@ +# Utility Guide: Load Properties + +The Load Properties Utility can be used to retrieve values from a properties file. + +## Table of Contents + +- [Utility Guide: Load Properties](#utility-guide-load-properties) + - [Table of Contents](#table-of-contents) + - [How This Works](#how-this-works) + - [Adding to the Properties File](#adding-to-the-properties-file) + - [Using the Load Properties Utility](#using-the-load-properties-utility) + - [Example Usage](#example-usage) + +## How This Works + +This utility uses the `jproperties` package to load the properties files.
+There is a class `PropertiesFile`, containing the locations of both files: + +1. `self.smokescreen_properties_file`: tests/smokescreen/bcss_smokescreen_tests.properties +2. `self.general_properties_file`: tests/bcss_tests.properties + +The method `get_properties()` will load either one of these based on the input provided.
+To ensure that there are no mistakes when providing this input there are two additional methods to call that will do this for you: + +1. `get_smokescreen_properties()`: Which will load `self.smokescreen_properties_file` +2. `get_general_properties()`: Which will load `self.general_properties_file` + +## Adding to the Properties File + +To add values to the properties file follow the format: + +```java +# ---------------------------------- +# Example Values +# ---------------------------------- +example_value_1=value1 +example_value_2=value2 +``` + +**Reasoning for storing values in the properties file:** + +1. Properties files use key-value pairs because they provide a simple, organized, and flexible way to store configuration data. + +2. Each line in the file assigns a value to a key (For example, c1_daily_invitation_rate=10). This makes it easy to look up and change values as needed. + +3. Using key-value pairs in properties files helps keep your tests clean, flexible, and easy to maintain by avoiding hard-coded values in your test scripts. + +**Why avoid hard coded values in tests?** + +1. Maintainability: If we need to update a value (like a test organization ID or a rate), we only have to change it in one place—the properties file—instead of searching through all your test code. + +2. Reusability: The same test code can be run with different data just by changing the properties file, making your tests more flexible. + +3. Separation of Concerns: Test logic stays in your code, while test data and configuration are kept separate in the properties file. + +4. Readability: It’s easier to see and manage all your test settings and data in one file. + +5. Environment Flexibility: We can have different properties files for different environments (e.g., Development, Test, Production) without changing your test code. + +## Using the Load Properties Utility + +To use this utility in a test reference the pytest fixture in `conftest.py`.
+There is no need to import anything as any fixtures in `conftest.py` will be automatically discovered by pytest. +Here there are two fixtures: + +1. `smokescreen_properties` - which is used to load the file: tests/smokescreen/bcss_smokescreen_tests.properties +2. `get_general_properties` - which is used to load the file: tests/bcss_tests.properties + +## Example Usage + +```python +def test_example_1(page: Page, general_properties: dict) -> None: + print( + general_properties["example_value_1"] + ) +``` diff --git a/docs/utility-guides/NHSNumberTools.md b/docs/utility-guides/NHSNumberTools.md index 344688f1..a53c020c 100644 --- a/docs/utility-guides/NHSNumberTools.md +++ b/docs/utility-guides/NHSNumberTools.md @@ -8,23 +8,51 @@ common functionality that may apply to many services in relation to NHS Number m - [Utility Guide: NHS Number Tools](#utility-guide-nhs-number-tools) - [Table of Contents](#table-of-contents) - [Using the NHS Number Tools class](#using-the-nhs-number-tools-class) - - [`spaced_nhs_number()`: Return Spaced NHS Number](#spaced_nhs_number-return-spaced-nhs-number) + - [`_nhs_number_checks()`: Checks if the NHS number is valid](#_nhs_number_checks-checks-if-the-nhs-number-is-valid) - [Required Arguments](#required-arguments) + - [Raises](#raises) + - [Example Usage for `_nhs_number_checks()`](#example-usage-for-_nhs_number_checks) + - [`spaced_nhs_number()`: Returns Spaced NHS Number](#spaced_nhs_number-returns-spaced-nhs-number) + - [Required Arguments](#required-arguments-1) - [Returns](#returns) + - [Example Usage for `spaced_nhs_number()`](#example-usage-for-spaced_nhs_number) ## Using the NHS Number Tools class You can initialise the NHS Number Tools class by using the following code in your test file: - from utils.nhs_number_tools import NHSNumberTools +```python +from utils.nhs_number_tools import NHSNumberTools +``` -## `spaced_nhs_number()`: Return Spaced NHS Number +## `_nhs_number_checks()`: Checks if the NHS number is valid -The `spaced_nhs_number()` method is designed to take the provided NHS number and return it in a formatted -string of the format `nnn nnn nnnn`. It's a static method so can be used in the following way: +The `_nhs_number_checks()` method does basic checks on NHS number value provided and raises an exception if the number is not valid - # Return formatted NHS number - spaced_nhs_number = NHSNumberTools.spaced_nhs_number("1234567890") +### Required Arguments + +The following are required for `_nhs_number_checks()`: + +| Argument | Format | Description | +| ---------- | ------ | ----------------------- | +| nhs_number | `str` | The NHS number to check | + +### Raises + +NHSNumberToolsException: If the NHS number is not numeric or not 10 digits long. + +## Example Usage for `_nhs_number_checks()` + +```python +from utils.nhs_number_tools import NHSNumberTools + incorrect_nhs_no = "A23456789" + NHSNumberTools._nhs_number_checks(incorrect_nhs_no) +``` + +## `spaced_nhs_number()`: Returns Spaced NHS Number + +The `spaced_nhs_number()` method is designed to take the provided NHS number and return it in a formatted +string of the format `nnn nnn nnnn`. It's a static method so can be used in the following way ### Required Arguments @@ -37,3 +65,11 @@ The following are required for `NHSNumberTools.spaced_nhs_number()`: ### Returns A `str` with the provided NHS number in `nnn nnn nnnn` format. For example, `NHSNumberTools.spaced_nhs_number(1234567890)` would return `123 456 7890`. + +## Example Usage for `spaced_nhs_number()` + +```python +from utils.nhs_number_tools import NHSNumberTools +# Return formatted NHS number + spaced_nhs_number = NHSNumberTools.spaced_nhs_number("1234567890") +``` diff --git a/docs/utility-guides/NotifyCriteriaParser.md b/docs/utility-guides/NotifyCriteriaParser.md new file mode 100644 index 00000000..937f5bff --- /dev/null +++ b/docs/utility-guides/NotifyCriteriaParser.md @@ -0,0 +1,107 @@ +# Utility Guide: Notify Criteria Parser + +**Source:** [`utils/notify_criteria_parser.py`](../../utils/notify_criteria_parser.py) + +The Notify Criteria Parser is a lightweight utility that extracts structured values from compact Notify message criteria strings. It is used by selection builders to support Notify filter logic—like `"S1 - new"` or `"S1 (S1w) - sending"` — by parsing these inputs into cleanly separated parts: `message type`, `message code (optional)`, and `status`. + +## Table of Contents + +- [Utility Guide: Notify Criteria Parser](#utility-guide-notify-criteria-parser) + - [Table of Contents](#table-of-contents) + - [Overview](#overview) + - [Using the Parser](#using-the-parser) + - [Expected Input Formats](#expected-input-formats) + - [Example Usage](#example-usage) + - [Output Structure](#output-structure) + - [Edge Case: none](#edge-case-none) + - [Error Handling](#error-handling) + - [Integration Points](#integration-points) + +--- + +## Overview + +Notify message filters are written as short text descriptions like "S1 - new" or "S1 (S1w) - sending". +The parser splits them into meaningful parts so that the system knows what message type to look for, whether there's a specific message code, and the message's status (like "new", "sending", etc). This parser breaks those strings into usable components for SQL query builders. + +- `"S1 - new"` +- `"S1 (S1w) - sending"` +- `"none"` + +--- + +## Using the Parser + +Import the parser function and give it a string like "S1 (S1w) - sending", and it gives you back each piece of information separately, like the message type, the code (if there is one), and the status. + +```python +from utils.notify_criteria_parser import parse_notify_criteria + +parts = parse_notify_criteria("S1 (S1w) - sending") +``` + +## Expected Input Formats + +The parser supports the following input patterns: + +| Format | Meaning | +| ------------------------- | ---------------------------------------------- | +| `Type - status` | e.g. `"S1 - new"` | +| `Type (Code) - status` | e.g. `"S1 (S1w) - sending"` | +| `none` (case-insensitive) | Special case meaning “no message should exist” | + +## Example Usage + +Here are a few examples of what the parser returns. Think of it like splitting a sentence into parts so each part can be used in a database search: + +```python +parse_notify_criteria("S2 (X9) - failed") +# ➜ {'type': 'S2', 'code': 'X9', 'status': 'failed'} + +parse_notify_criteria("S1 - new") +# ➜ {'type': 'S1', 'code': None, 'status': 'new'} + +parse_notify_criteria("None") +# ➜ {'status': 'none'} +``` + +## Output Structure + +The returned value is a dictionary containing: + +```python +{ + "type": str, # the main message group (like S1 or M1) + "code": Optional[str], # the specific version (optional) + "status": str # the message’s progress, such as "new", "sending", or "none" +} +``` + +## Edge Case: none + +If someone enters `none` as the criteria, it means "we're looking for subjects who do not have a matching message." The parser handles this specially, and the SQL builder will write `NOT EXISTS` logic behind the scenes to exclude those cases, so the parser returns: + +```python +{'status': 'none'} +``` + +This signals `NOT EXISTS` logic for Notify message filtering. + +## Error Handling + +If the input doesn’t match an expected pattern, the parser raises: + +```python +ValueError("Invalid Notify criteria format: 'your_input'") +``` + +e.g. If a tester or user types something like `S1 - banana` or forgets the - status bit, the parser will throw an error. This helps catch typos or unsupported formats early. + +## Integration Points + +These are the parts of the system that use the parser to decide whether to include or exclude Notify messages from a search: +`SubjectSelectionQueryBuilder._add_criteria_notify_queued_message_status()` – for messages currently in the system + +`SubjectSelectionQueryBuilder._add_criteria_notify_archived_message_status()` – for messages already sent or stored + +You can also reuse it in any other part of the system that needs to interpret Notify message filters. diff --git a/docs/utility-guides/Oracle.md b/docs/utility-guides/Oracle.md new file mode 100644 index 00000000..e3f8be19 --- /dev/null +++ b/docs/utility-guides/Oracle.md @@ -0,0 +1,111 @@ +# Utility Guide: Oracle + +The Oracle Utility provides an easy way to interact with an Oracle database directly from your Playwright test suite.It can be used to run SQL queries or stored procedures on the Oracle database. + +## When and Why to Use the Oracle Utility + +You might need to use this utility in scenarios such as: + +- To run SQL queries or stored procedures on the Oracle database. +- Verifying that data is correctly written to or updated in the database after a workflow is completed in your application. + +## Table of Contents + +- [Utility Guide: Oracle](#utility-guide-oracle) + - [When and Why to Use the Oracle Utility](#when-and-why-to-use-the-oracle-utility) + - [Table of Contents](#table-of-contents) + - [Using the Oracle Utility](#using-the-oracle-utility) + - [Required arguments](#required-arguments) + - [Oracle Utility Methods](#oracle-utility-methods) + - [Example usage](#example-usage) + - [Oracle Specific Functions](#oracle-specific-functions) + - [How to Add New Oracle-Specific Functions](#how-to-add-new-oracle-specific-functions) + - [Example Usage](#example-usage-1) + +## Using the Oracle Utility + +To use the Oracle Utility, import the 'OracleDB' class into your test file and then call the OracleDB methods from within your tests, as required. + +## Required arguments + +The functions in this class require different arguments.
+Look at the docstrings for each function to see what arguments are required.
+The docstrings also specify when arguments are optional, and what the default values are when no argument is provided. + +## Oracle Utility Methods + +**The main methods provided by OracleDB are:** + +- **connect_to_db(self)**: Connects to the Oracle database using credentials from environment variables. +- **disconnect_from_db(self, conn)**: Closes the provided Oracle database connection. +- **execute_query(self, query, params=None)**: Executes a SQL query with optional parameters and returns the results as a pandas DataFrame. +- **execute_stored_procedure(self, `procedure_name`, params=None)**: Executes a named stored procedure with optional parameters. +- **exec_bcss_timed_events(self, nhs_number_df)**: Runs the `bcss_timed_events` stored procedure for each NHS number provided in a DataFrame. +- **get_subject_id_from_nhs_number(self, nhs_number)**: Retrieves the `subject_screening_id` for a given NHS number. + +For full implementation details, see utils/oracle/oracle.py. + +## Example usage + +```python +from utils.oracle.oracle import OracleDB + +def test_oracle_query() -> None: + query = """ + SELECT column_a, + column_b, + column_c + FROM example_table + WHERE condition_1 = :condition1 + AND condition_2 = :condition2 + """ + params = { + "condition1": 101, + "condition2": 202, + } + result_df = OracleDB().execute_query(query, params) + print(result_df) + +def run_stored_procedure() -> None: + OracleDB().execute_stored_procedure("bcss_timed_events") +``` + +--- + +## Oracle Specific Functions + +This contains SQL queries that can be used to run tests.
+These are all stored in one location to make it easier to edit the query at a later date and to make it accessible to multiple tests. + +Common values are placed in the `SqlQueryValues` class to avoid repeating the same values in the queries. + +## How to Add New Oracle-Specific Functions + +- Define a new function in `utils/oracle/oracle_specific_functions.py`. +- Create your SQL query, `parameterizing` as needed. +- Call the relevant methods from the oracle `util` based on your needs (e.g., `execute_query`, stored procedure methods, etc.). +- Return the result in the appropriate format for your function. +- Document the function with a clear docstring. + +## Example Usage + +```python +from utils.oracle.oracle import OracleDB + +def example_query() -> pd.DataFrame: + """ + Example function to demonstrate OracleDB usage for querying subject NHS numbers. + Returns: + pd.DataFrame: Query results as a pandas DataFrame. + """ + example_df = OracleDB().execute_query( + f"""subject_nhs_number + from ep_subject_episode_t + where se.latest_event_status_id in ({SqlQueryValues.S10_EVENT_STATUS}, {SqlQueryValues.S19_EVENT_STATUS})""") + + return example_df +``` + +> **Note:**
+> The Oracle utility and its helper functions are available under utils/oracle/.
+> See the source code for more details. diff --git a/docs/utility-guides/PDFReader.md b/docs/utility-guides/PDFReader.md new file mode 100644 index 00000000..1b0a5157 --- /dev/null +++ b/docs/utility-guides/PDFReader.md @@ -0,0 +1,64 @@ +# Utility Guide: PDF Reader + +The PDF Reader utility allows for reading PDF files and extracting NHS numbers from them. + +## Table of Contents + +- [Utility Guide: PDF Reader](#utility-guide-pdf-reader) + - [Table of Contents](#table-of-contents) + - [Functions Overview](#functions-overview) + - [Extract NHS No From PDF](#extract-nhs-no-from-pdf) + - [Required Arguments](#required-arguments) + - [How This Function Works](#how-this-function-works) + - [Example Usage](#example-usage) + +## Functions Overview + +For this utility, the following function is available: + +- `extract_nhs_no_from_pdf` + +### Extract NHS No From PDF + +This function extracts all NHS numbers from a PDF file by searching for the string **"NHS No:"** on each page. + +#### Required Arguments + +- `file`: + - Type: `str` + - The file path to the PDF file as a string. + +#### How This Function Works + +1. Loads the PDF file using the `PdfReader` object from the `pypdf` package. +2. Loops through each page of the PDF. +3. Searches for the string *"NHS No"* on each page. +4. If found, extracts the NHS number, removes any whitespaces, and adds it to a pandas DataFrame (`nhs_no_df`). +5. If no NHS numbers are found on that page, it goes to the next page. +6. Returns the DataFrame containing all extracted NHS numbers. + +#### Example Usage + +You can use this utility to extract NHS numbers from a PDF file as part of the [`Batch Processing`](BatchProcessing.md) utility or by providing the file path as a string. + +**Extracting NHS numbers using a file path:** + +```python +from utils.pdf_reader import extract_nhs_no_from_pdf +file_path = "path/to/your/file.pdf" +nhs_no_df = extract_nhs_no_from_pdf(file_path) +``` + +**Extracting NHS numbers using batch processing:** + +```python +from utils.pdf_reader import extract_nhs_no_from_pdf +get_subjects_from_pdf = True +file = download_file.suggested_filename # This is done via playwright when the "Retrieve button" on a batch is clicked. + +nhs_no_df = ( + extract_nhs_no_from_pdf(file) + if file.endswith(".pdf") and get_subjects_from_pdf + else None + ) +``` diff --git a/docs/utility-guides/ScreeningSubjectPageSearcher.md b/docs/utility-guides/ScreeningSubjectPageSearcher.md new file mode 100644 index 00000000..3289dc5f --- /dev/null +++ b/docs/utility-guides/ScreeningSubjectPageSearcher.md @@ -0,0 +1,111 @@ +# Utility Guide: Screening Subject Page Searcher + +The Screening Subject Search utility provides methods for: + +- Searching for subjects using various parameters (NHS Number, forename, surname, DOB, postcode, episode closed date, status, or latest event status) +- Verifying a subject's event status using their NHS number + +## Table of Contents + +- [Utility Guide: Screening Subject Page Searcher](#utility-guide-screening-subject-page-searcher) + - [Table of Contents](#table-of-contents) + - [Functions Overview](#functions-overview) + - [The page object parameter](#the-page-object-parameter) + - [Function Summaries \& Example Usage](#function-summaries--example-usage) + - [verify\_subject\_event\_status\_by\_nhs\_no](#verify_subject_event_status_by_nhs_no) + - [Search Functions](#search-functions) + - [check\_clear\_filters\_button\_works](#check_clear_filters_button_works) + +## Functions Overview + +The following functions are available: + +- `verify_subject_event_status_by_nhs_no` +- `search_subject_by_nhs_number` +- `search_subject_by_surname` +- `search_subject_by_forename` +- `search_subject_by_dob` +- `search_subject_by_postcode` +- `search_subject_by_episode_closed_date` +- `search_subject_by_status` +- `search_subject_by_latest_event_status` +- `search_subject_by_search_area` +- `check_clear_filters_button_works` + +### The page object parameter + +All functions require the Playwright `page` object as their first argument. + +--- + +### Function Summaries & Example Usage + +#### verify_subject_event_status_by_nhs_no + +Navigates to the subject screening search page and searches for the provided NHS Number. Then this checks that the latest event status of the subject matches the expected value(s). + +**Arguments:** + +- `page`: Playwright page object +- `nhs_no`: `str` — The subject's NHS number +- `latest_event_status`: `str | list[str]` — The expected status or list of statuses to verify + +**Example:** + +```python +verify_subject_event_status_by_nhs_no(page, "1234567890", "S61 - Normal (No Abnormalities Found)") +verify_subject_event_status_by_nhs_no(page, "1234567890", ["S61 - Normal (No Abnormalities Found)", "A158 - High-risk findings"]) +``` + +--- + +#### Search Functions + +All search functions follow a similar pattern: they clear filters, fill in the relevant field, and perform a search. + +**Available search functions:** + +- `search_subject_by_nhs_number(page, nhs_no: str)` +- `search_subject_by_surname(page, surname: str)` +- `search_subject_by_forename(page, forename: str)` +- `search_subject_by_dob(page, dob: str)` +- `search_subject_by_postcode(page, postcode: str)` +- `search_subject_by_episode_closed_date(page, episode_closed_date: str)` +- `search_subject_by_status(page, status: str)` +- `search_subject_by_latest_event_status(page, status: str)` +- `search_subject_by_search_area(page, status: str, search_area: str, code: str = None, gp_practice_code: str = None)` + +**Example:** + +```python +search_subject_by_nhs_number(page, "1234567890") +search_subject_by_surname(page, "Smith") +search_subject_by_forename(page, "John") +search_subject_by_dob(page, "1970-01-01") +search_subject_by_postcode(page, "AB12 3CD") +search_subject_by_episode_closed_date(page, "2023-12-31") +search_subject_by_status(page, "Call") +search_subject_by_latest_event_status(page, "S61 - Normal (No Abnormalities Found)") +search_subject_by_search_area(page, "Call", "Whole Database", code="XYZ", gp_practice_code="ABC123") +``` + +--- + +#### check_clear_filters_button_works + +Checks that the "clear filters" button works as intended. It enters the provided NHS number, clicks the clear filters button, and then verifies that the filters are cleared. + +**Arguments:** + +- `page`: Playwright page object +- `nhs_no`: `str` — The subject's NHS number + +**Example:** + +```python +check_clear_filters_button_works(page, "1234567890") +``` + +--- + +For more details on each function's implementation, refer to the source code. diff --git a/docs/utility-guides/SubjectDemographics.md b/docs/utility-guides/SubjectDemographics.md new file mode 100644 index 00000000..20df6972 --- /dev/null +++ b/docs/utility-guides/SubjectDemographics.md @@ -0,0 +1,85 @@ +# Utility Guide: Subject Demographics + +The Subject Demographics utility provides helper methods for interacting with and updating subject demographic data within the BCSS Playwright automation framework.
+This includes: + +1. Updating a subject's date of birth (DOB) to a random value within specific age ranges. +2. Navigating to the subject demographic page and updating fields such as postcode and DOB. + +## Table of Contents + +- [Utility Guide: Subject Demographics](#utility-guide-subject-demographics) + - [Table of Contents](#table-of-contents) + - [Using the SubjectDemographicUtil class](#using-the-subjectdemographicutil-class) + - [Updating DOB](#updating-dob) + - [Arguments](#arguments) + - [How to use this method](#how-to-use-this-method) + - [Other Utility Methods](#other-utility-methods) + +## Using the SubjectDemographicUtil class + +You can initialise the SubjectDemographicUtil class by using the following code in your test file: + +```python +from utils.subject_demographics import SubjectDemographicUtil +``` + +## Updating DOB + +The `update_subject_dob` method allows you to update a subject's date of birth, either to a specific value or to a random value within a chosen age range. The method will automatically navigate to the subject demographic page, fill in required fields (such as postcode if missing), and update the DOB. + +### Arguments + +- `nhs_no`: + - Type: `str` + - The NHS number of the subject you want to update. +- `random_dob`: + - Type: `bool` + - If `True`, the DOB will be set to a random value within the specified age range. + - If `False`, the DOB will be set to the value provided in `new_dob`. +- `younger_subject`: + - Type: `bool | None` + - Determines the age range for the random DOB update (only used if `random_dob` is `True`): + - `True`: Random age between 50-70 years old. + - `False`: Random age between 75-100 years old. + - `None`: Defaults to `False` (75-100 years old). +- `new_dob`: + - Type: `datetime | None` + - The new date of birth to set (only used if `random_dob` is `False`). + +### How to use this method + +To update a subject's DOB to a random value between 50-70: + +```python +nhs_no = "9468743977" +SubjectDemographicUtil(page).update_subject_dob(nhs_no, random_dob=True, younger_subject=True) +``` + +To update a subject's DOB to a random value between 75-100: + +```python +nhs_no = "9468743977" +SubjectDemographicUtil(page).update_subject_dob(nhs_no, random_dob=True, younger_subject=False) +``` + +To update a subject's DOB to a specific date: + +```python +from datetime import datetime +nhs_no = "9468743977" +new_dob = datetime(1960, 5, 15) +SubjectDemographicUtil(page).update_subject_dob(nhs_no, random_dob=False, new_dob=new_dob) +``` + +## Other Utility Methods + +- `random_dob_within_range(younger: bool) -> datetime` + - Generates a random date of birth within the specified age range: + - If `younger` is `True`, returns a DOB for age 50-70. + - If `younger` is `False`, returns a DOB for age 75-100. + +- `random_datetime(start: datetime, end: datetime) -> datetime` + Generates a random date between two `datetime` objects. + +Refer to the source code in `utils/subject_demographics.py` for more details on available methods and their usage. diff --git a/docs/utility-guides/SubjectNotes.md b/docs/utility-guides/SubjectNotes.md new file mode 100644 index 00000000..d38e1f4b --- /dev/null +++ b/docs/utility-guides/SubjectNotes.md @@ -0,0 +1,28 @@ +# Utility Guide: Note Test Utilities + +The **Note Test Utility** module provides reusable helper functions for verifying and comparing note data during test automation of screening subjects in BCSS. +It includes helpers for: + +1. Fetching supporting notes from the database. +2. Verifying that a note matches expected values from the DB or UI. +3. Confirming that a removed note is archived properly as obsolete. + +## Table of Contents + +- [Utility Guide: Note Test Utilities](#utility-guide-note-test-utilities) + - [Table of Contents](#table-of-contents) + - [Using These Utilities](#using-these-utilities) + +--- + +## Using These Utilities + +You can import functions into your test files like so: + +```python +from utils.note_test_util import ( + fetch_supporting_notes_from_db, + verify_note_content_matches_expected, + verify_note_content_ui_vs_db, + verify_note_removal_and_obsolete_transition, +) diff --git a/docs/utility-guides/SubjectSelectionQueryBuilder.md b/docs/utility-guides/SubjectSelectionQueryBuilder.md new file mode 100644 index 00000000..0fc91eb1 --- /dev/null +++ b/docs/utility-guides/SubjectSelectionQueryBuilder.md @@ -0,0 +1,192 @@ +# SubjectSelectionQueryBuilder Utility Guide + +## Overview + +The `SubjectSelectionQueryBuilder` is a flexible utility for constructing SQL queries to retrieve screening subjects from the NHS BCSS Oracle database. It supports a comprehensive set of filters (subject selection criteria) based on: + +- Demographics and GP details +- Screening status and due dates +- Events and episodes +- Kit usage and diagnostic activity +- Appointments, tests, and CADS datasets +- Lynch pathway logic and Notify message status + +For example: + +- NHS number +- Subject age +- Hub code +- Screening centre code +- GP practice linkage +- Screening status +- and many more, (including date-based and status-based filters). + +It also handles special cases such as `unchanged` values and supports modifiers like `NOT:` for negation. + +Queries are constructed dynamically based on `criteria`, `user`, and `subject` inputs. + +--- + +## How to Use + +### Import and Instantiate the Builder + +```python +from utils.oracle.subject_selection_query_builder import SubjectSelectionQueryBuilder + +builder = SubjectSelectionQueryBuilder() +``` + +### Build a subject selection query + +Using `build_subject_selection_query`: + +```python +query, bind_vars = builder.build_subject_selection_query( + criteria=criteria_dict, # Dict[str, str] of selection criteria + user=test_user, # User object + subject=test_subject, # Subject object + subjects_to_retrieve=100 # Optional limit +) +``` + +When you call `build_subject_selection_query(...)`, it returns a tuple containing: + +`query` — a complete SQL string with placeholders like :nhs_number, ready to be run against the database. + +`bind_vars` — a dictionary mapping those placeholders to their actual values, like {"nhs_number": "1234567890"}. + +This approach ensures injection-safe execution (defending against SQL injection attacks) and allows database engines to optimize and cache query plans for repeated execution. + +## Example Usage + +### Input + +```python +criteria = { + "nhs_number": "1234567890", + "screening_status": "invited" +} + +user = User(user_id=42, organisation=None) # Optional; used for 'unchanged' logic +subject = Subject() # Optional; used for 'unchanged' logic + +builder = SubjectSelectionQueryBuilder() +query, bind_vars = builder.build_subject_selection_query(criteria, user, subject) +``` + +### Output + +#### Query + +```SQL +SELECT ss.screening_subject_id, ... +FROM screening_subject_t ss +INNER JOIN sd_contact_t c ON c.nhs_number = ss.subject_nhs_number +WHERE 1=1 + AND c.nhs_number = :nhs_number + AND ss.screening_status_id = 1001 +FETCH FIRST 1 ROWS ONLY +``` + +(Note: 1001 would be the resolved ID for "invited" in ScreeningStatusType.) + +#### bind_vars + +```python +{ + "nhs_number": "1234567890" +} +``` + +### What happens next? + +You can pass both values directly into your DB layer or test stub: + +```python +from utils.oracle.oracle import OracleDB +df = OracleDB().execute_query(query, bind_vars) +``` + +## Supported Inputs + +### 1. criteria (Dict[str, str]) + +This is the main filter configuration, where each entry represents one selection condition. + +The `key` is a string matching one of the `SubjectSelectionCriteriaKey` `values` (e.g. "SUBJECT_AGE", "SCREENING_STATUS", etc.) + +The `value` is the actual filter (e.g. "55", "> 60", "yes", "not:null", etc.) + +Example: + +```python +{ + "subject_has_event_status": "ES01", + "subject_age": "> 60", + "date_of_death": "null" +} +``` + +Each of those triggers a different clause in the generated SQL. + +### 2. user (User) + +This gives the builder context about who’s requesting the query, including their organisation and permissions. + +Some criteria (like "USER_HUB" or "USER_ORGANISATION") don’t refer to a fixed hub code, but instead dynamically map to the hub or screening centre of the user running the search. That’s where this comes into play. + +Example: + +```python +"SUBJECT_HUB_CODE": "USER_HUB" +``` + +This means “filter by the hub assigned to this user’s organisation,” not a fixed hub like ABC. + +### 3. subject (Subject) + +This is an optional parameter that provides context about the subject being queried. It’s particularly important for criteria that require comparison against existing values in the database, such as "unchanged" logic. +It allows the builder to determine if a subject's current value matches a previously recorded value. + +If you want to filter subjects based on their current screening status, for example, you would need to provide a `Subject` object. +To know if you need to populate an attribute like `screening_status_id`, you can do so by looking if the method requires the Subject class. + +For example, if you look at the following code for a screening status, you can see that the Subject class is required. + +```python +case SubjectSelectionCriteriaKey.SCREENING_STATUS: + self._add_criteria_screening_status(subject) +``` + +You can set attributes on the `Subject` object like this: + +```python +subject = Subject() +subject.set_nhs_number("1234567890") +subject.set_screening_status_id(1001) +``` + +This allows the builder to use the subject's current screening status in the query. + +Together, these three inputs give the builder all it needs to translate human-friendly selection criteria into valid, safe, dynamic SQL. + +## Key Behavior Details + +Values like `yes`, `no`, `null`, `not null`, `unchanged` are normalized and interpreted internally. + +`NOT:` prefix in values flips logic where allowed (e.g. "NOT:ES01"). + +Most enums (like `YesNoType`, `ScreeningStatusType`, etc.) are resolved by description using .by_description() or .by_description_case_insensitive() calls. + +Joins to related datasets are added dynamically only when required (e.g. latest episode, diagnostic test joins). + +All dates are handled via Oracle `TRUNC(SYSDATE)` and `TO_DATE()` expressions to ensure consistent date logic. + +## Reference + +For a full list of supported `SubjectSelectionCriteriaKey` values and expected inputs, refer to the enumeration in: + +`classes/subject_selection_criteria_key.py` + +Or explore the full `SubjectSelectionQueryBuilder._dispatch_criteria_key()` method to review how each key is implemented. diff --git a/docs/utility-guides/TableUtil.md b/docs/utility-guides/TableUtil.md new file mode 100644 index 00000000..5549291f --- /dev/null +++ b/docs/utility-guides/TableUtil.md @@ -0,0 +1,202 @@ +# Utility Guide: Table Utility + +The Table Utilities module (`utils/table_util.py`) provides helper functions to interact with and validate HTML tables in Playwright-based UI tests. +**This utility is designed to be used inside Page Object Model (POM) classes** to simplify table interactions and assertions. + +## Table of Contents + +- [Utility Guide: Table Utility](#utility-guide-table-utility) + - [Table of Contents](#table-of-contents) + - [Using the Table Utility](#using-the-table-utility) + - [Example usage](#example-usage) + - [Get Column Index](#get-column-index) + - [Example](#example) + - [Click First Link In Column](#click-first-link-in-column) + - [Example](#example-1) + - [Click First Input In Column](#click-first-input-in-column) + - [Example](#example-2) + - [Format Inner Text](#format-inner-text) + - [Example](#example-3) + - [Get Table Headers](#get-table-headers) + - [Example](#example-4) + - [Get Row Count](#get-row-count) + - [Example](#example-5) + - [Pick Row](#pick-row) + - [Example](#example-6) + - [Pick Random Row](#pick-random-row) + - [Example](#example-7) + - [Pick Random Row Number](#pick-random-row-number) + - [Example](#example-8) + - [Get Row Data With Headers](#get-row-data-with-headers) + - [Example](#example-9) + - [Get Full Table With Headers](#get-full-table-with-headers) + - [Example](#example-10) + +## Using the Table Utility + +To use the Table Utility, import the `TableUtils` class into your Page Object Model (POM) file and instantiate it for each table you want to interact with. + +```python +from utils.table_util import TableUtils + +class ReportsPage(BasePage): + """Reports Page locators, and methods for interacting with the page.""" + + def __init__(self, page): + super().__init__(page) + self.page = page + + # Initialize TableUtils for different tables by passing the page and table selector + self.reports_table = TableUtils(page, "#listReportDataTable") + self.subjects_table = TableUtils(page, "#subjInactiveOpenEpisodes") +``` + +## Example usage + +Below are examples of how to use `TableUtils` methods inside your POM methods or tests: + +```python +# Click the first NHS number link in the reports table +self.reports_table.click_first_link_in_column("NHS Number") + +# Get the index of the "Status" column +status_col_index = self.reports_table.get_column_index("Status") + +# Click the first checkbox in the "Select" column +self.subjects_table.click_first_input_in_column("Select") + +# Get all table headers as a dictionary +headers = self.reports_table.get_table_headers() + +# Get the number of visible rows in the table +row_count = self.reports_table.get_row_count() + +# Pick a specific row (e.g., row 2) +row_locator = self.reports_table.pick_row(2) + +# Pick a random row and click a link in the "Details" column +random_row = self.reports_table.pick_random_row() +random_row.locator("td").nth(self.reports_table.get_column_index("Details") - 1).locator("a").click() + +# Get data for a specific row as a header-value dictionary +row_data = self.reports_table.get_row_data_with_headers(1) + +# Get the entire table as a dictionary of rows +full_table = self.reports_table.get_full_table_with_headers() +``` + +--- + +### Get Column Index + +Returns the index (1-based) of a specified column name. Returns -1 if not found. + +#### Example + +```python +col_index = self.reports_table.get_column_index("Date") +``` + +### Click First Link In Column + +Clicks on the first hyperlink present in a specified column. + +#### Example + +```python +self.reports_table.click_first_link_in_column("NHS Number") +``` + +### Click First Input In Column + +Clicks on the first input element (e.g., checkbox/radio) in a specific column. + +#### Example + +```python +self.reports_table.click_first_input_in_column("Select") +``` + +### Format Inner Text + +Formats inner text of a row string into a dictionary. + +#### Example + +```python +row_dict = self.reports_table.format_inner_text("123\tJohn Doe\tActive") +``` + +### Get Table Headers + +Extracts and returns table headers as a dictionary. + +#### Example + +```python +headers = self.reports_table.get_table_headers() +``` + +### Get Row Count + +Returns the count of visible rows in the table. + +#### Example + +```python +count = self.reports_table.get_row_count() +``` + +### Pick Row + +Returns a locator for a specific row (1-based). + +#### Example + +```python +row_locator = self.reports_table.pick_row(3) +``` + +### Pick Random Row + +Picks and returns a locator for a random visible row. + +#### Example + +```python +random_row = self.reports_table.pick_random_row() +``` + +### Pick Random Row Number + +Returns the number of a randomly selected row. + +#### Example + +```python +random_row_number = self.reports_table.pick_random_row_number() +``` + +### Get Row Data With Headers + +Returns a dictionary of header-value pairs for a given row. + +#### Example + +```python +row_data = self.reports_table.get_row_data_with_headers(2) +``` + +### Get Full Table With Headers + +Constructs a dictionary of the entire table content. + +#### Example + +```python +full_table = self.reports_table.get_full_table_with_headers() +``` + +--- + +For more details on each function's implementation, refer to the source code in `utils/table_util.py`. diff --git a/docs/utility-guides/UserTools.md b/docs/utility-guides/UserTools.md index adb32b56..e4aa121f 100644 --- a/docs/utility-guides/UserTools.md +++ b/docs/utility-guides/UserTools.md @@ -1,6 +1,6 @@ # Utility Guide: User Tools -The User Tools utility provided by this blueprint allows for the easy management of test users via a json file included +The User Tools utility provided by this blueprint allows for the easy management of test users via a `users.json` file included at the base of the repository. ## Table of Contents @@ -10,56 +10,85 @@ at the base of the repository. - [Using the User Tools class](#using-the-user-tools-class) - [Managing Users](#managing-users) - [Considering Security](#considering-security) - - [`retrieve_user()`: Retrieve User Details](#retrieve_user-retrieve-user-details) + - [`user_login()`: Log In as a User](#user_login-log-in-as-a-user) - [Required Arguments](#required-arguments) - - [Returns](#returns) - [Example Usage](#example-usage) + - [`retrieve_user()`: Retrieve User Details](#retrieve_user-retrieve-user-details) + - [Required Arguments](#required-arguments-1) + - [Returns](#returns) + - [Example Usage](#example-usage-1) ## Using the User Tools class -You can initialise the User Tools class by using the following code in your test file: +You can use the User Tools class by importing it in your test file: - from utils.user_tools import UserTools +```python +from utils.user_tools import UserTools +``` This module has been designed as a static class, so you do not need to instantiate it when you want to retrieve any user information. ## Managing Users -For this class, users are managed via the [users.json](../../users.json) file provided with this repository. For any new users you need to -add, the idea is to just add a new record, with any appropriate metadata you need for the user whilst they interact with your application. +For this class, users are managed via the [`users.json`](../../users.json) file provided with this repository. For any new users you need to add, simply add a new record with any appropriate metadata you need for the user whilst they interact with your application. For example, adding a record like so (this example shows the entire `users.json` file): - { - "Documentation User": { - "username": "DOC_USER", - "roles": ["Example Role A"], - "unique_id": 42 - } - } - -The data you require for these users can be completely customised for what information you need, so whilst the example shows `username`, `roles` -and `unique_id` as possible values we may want to use, this is not an exhaustive list. The key that is used (so in this example, `"Documentation User"`) -is also customisable and should be how you want to easily reference retrieving this user in your tests. +```json +{ + "Hub Manager State Registered at BCS01": { + "username": "BCSS401", + "roles": [ + "Hub Manager State Registered, Midlands and North West" + ] + } +} +``` ### Considering Security -An important note on managing users in this way is that passwords or security credentials should **never** be stored in the `users.json` file. These -are considered secrets, and whilst it may be convenient to store them in this file, it goes against the +An important note on managing users in this way is that passwords or security credentials should **never** be stored in the `users.json` file. These are considered secrets, and whilst it may be convenient to store them in this file, it goes against the [security principles outlined in the Software Engineering Quality Framework](https://github.com/NHSDigital/software-engineering-quality-framework/blob/main/practices/security.md#application-level-security). With this in mind, it's recommended to do the following when it comes to managing these types of credentials: -- When running locally, store any secret values in a local configuration file and set this file in `.gitignore` so it is not committed to the codebase. +- When running locally, store any secret values in a local configuration file such as `local.env`. This file is created by running the script [`setup_env_file.py`](../../setup_env_file.py) and is included in `.gitignore` so it is not committed to the codebase. - When running via a CI/CD process, store any secret values in an appropriate secret store and pass the values into pytest at runtime. +## `user_login()`: Log In as a User + +The `user_login()` method allows you to log in to the BCSS application as a specified user. It retrieves the username from the `users.json` file and the password from the `local.env` file (using the `BCSS_PASS` environment variable). + +### Required Arguments + +| Argument | Format | Description | +|-----------|--------|--------------------------------------------------------------------| +| page | `Page` | The Playwright page object to interact with. | +| username | `str` | The key from `users.json` for the user you want to log in as. | + +### Example Usage + +```python +from utils.user_tools import UserTools + +def test_login_as_user(page): + UserTools.user_login(page, "Hub Manager State Registered at BCS01") +``` + +> **Note:** +> Ensure you have set the `BCSS_PASS` environment variable in your `local.env` file (created by running `setup_env_file.py`) before using this method. + +--- + ## `retrieve_user()`: Retrieve User Details The `retrieve_user()` method is designed to easily retrieve the data for a specific user entry from the `users.json` file. This is a static method, so can be called using the following logic: - # Retrieving documentation user details from example - user_details = UserTools.retrieve_user("Documentation User") +```python +# Retrieving documentation user details from example +user_details = UserTools.retrieve_user("Hub Manager State Registered at BCS01") +``` ### Required Arguments @@ -77,13 +106,18 @@ A Python `dict` object that contains the values associated with the provided use When using a `users.json` file as set up in the example above: - from utils.user_tools import UserTools - from playwright.sync_api import Page +```python +from utils.user_tools import UserTools +from playwright.sync_api import Page + +def test_login(page: Page) -> None: + # Retrieving documentation user details from example + user_details = UserTools.retrieve_user("Hub Manager State Registered at BCS01") + + # Use values to populate a form + page.get_by_role("textbox", name="Username").fill(user_details["username"]) +``` - def test_login(page: Page) -> None: - # Retrieving documentation user details from example - user_details = UserTools.retrieve_user("Documentation User") +--- - # Use values to populate a form - page.get_by_role("textbox", name="Username").fill(user_details["username"]) - page.get_by_role("textbox", name="ID").fill(user_details["unique_id"]) +For more details on each function's implementation, refer to the source code in `utils/user_tools.py`. diff --git a/pages/base_page.py b/pages/base_page.py new file mode 100644 index 00000000..186164b7 --- /dev/null +++ b/pages/base_page.py @@ -0,0 +1,287 @@ +from playwright.sync_api import Page, expect, Locator, Dialog +import logging + + +class BasePage: + """Base Page locators and methods for interacting with the page.""" + + def __init__(self, page: Page): + self.page = page + # Homepage - vars + self.main_menu_string = "Main Menu" + # Homepage/Navigation Bar links + self.sub_menu_link = self.page.get_by_role("link", name="Show Sub-menu") + self.hide_sub_menu_link = self.page.get_by_role("link", name="Hide Sub-menu") + self.select_org_link = self.page.get_by_role("link", name="Select Org") + self.back_button = self.page.get_by_role("link", name="Back", exact=True) + self.release_notes_link = self.page.get_by_role("link", name="- Release Notes") + self.refresh_alerts_link = self.page.get_by_role("link", name="Refresh alerts") + self.user_guide_link = self.page.get_by_role("link", name="User guide") + self.help_link = self.page.get_by_role("link", name="Help") + self.main_menu_link = self.page.get_by_role("link", name=self.main_menu_string) + self.log_out_link = self.page.get_by_role("link", name="Log-out") + # Main menu - page links + self.contacts_list_page = self.page.get_by_role("link", name="Contacts List") + self.bowel_scope_page = self.page.get_by_role("link", name="Bowel Scope") + self.call_and_recall_page = self.page.get_by_role( + "link", name="Call and Recall" + ) + self.communications_production_page = self.page.get_by_role( + "link", name="Communications Production" + ) + self.download_page = self.page.get_by_role("link", name="Download") + self.fit_test_kits_page = self.page.get_by_role("link", name="FIT Test Kits") + self.gfobt_test_kits_page = self.page.get_by_role( + "link", name="gFOBT Test Kits" + ) + self.lynch_surveillance_page = self.page.get_by_role( + "link", name="Lynch Surveillance" + ) + self.organisations_page = self.page.get_by_role("link", name="Organisations") + self.reports_page = self.page.get_by_role("link", name="Reports") + self.screening_practitioner_appointments_page = self.page.get_by_role( + "link", name="Screening Practitioner Appointments" + ) + self.screening_subject_search_page = self.page.get_by_role( + "link", name="Screening Subject Search" + ) + # Bowel Cancer Screening System header + self.bowel_cancer_screening_system_header = self.page.locator("#ntshAppTitle") + # Bowel Cancer Screening Page header + self.bowel_cancer_screening_page_title = self.page.locator("#page-title") + self.bowel_cancer_screening_ntsh_page_title = self.page.locator( + "#ntshPageTitle" + ) + self.main_menu__header = self.page.locator("#ntshPageTitle") + + def click_main_menu_link(self) -> None: + """Click the Base Page 'Main Menu' link if it is visible.""" + loops = 0 + text = None + + while loops <= 3: + if self.main_menu_link.is_visible(): + self.click(self.main_menu_link) + try: + if self.main_menu__header.is_visible(): + text = self.main_menu__header.text_content() + else: + text = None + except Exception as e: + logging.warning(f"Could not read header text: {e}") + text = None + + if text and self.main_menu_string in text: + return # Success + else: + logging.warning("Main Menu click failed, retrying after 0.2 seconds") + # The timeout is in place here to allow the page ample time to load if it has not already been loaded + self.page.wait_for_timeout(200) + + loops += 1 + # All attempts failed + raise RuntimeError( + f"Failed to navigate to Main Menu after {loops} attempts. Last page title was: '{text or 'unknown'}'" + ) + + def click_log_out_link(self) -> None: + """Click the Base Page 'Log-out' link.""" + self.click(self.log_out_link) + + def click_sub_menu_link(self) -> None: + """Click the Base Page 'Show Sub-menu' link.""" + self.click(self.sub_menu_link) + + def click_hide_sub_menu_link(self) -> None: + """Click the Base Page 'Hide Sub-menu' link.""" + self.click(self.hide_sub_menu_link) + + def click_select_org_link(self) -> None: + """Click the Base Page 'Select Org' link.""" + self.click(self.select_org_link) + + def click_back_button(self) -> None: + """Click the Base Page 'Back' button.""" + self.click(self.back_button) + + def click_release_notes_link(self) -> None: + """Click the Base Page 'Release Notes' link.""" + self.click(self.release_notes_link) + + def click_refresh_alerts_link(self) -> None: + """Click the Base Page 'Refresh alerts' link.""" + self.click(self.refresh_alerts_link) + + def click_user_guide_link(self) -> None: + """Click the Base Page 'User guide' link.""" + self.click(self.user_guide_link) + + def click_help_link(self) -> None: + """Click the Base Page 'Help' link.""" + self.click(self.help_link) + + def bowel_cancer_screening_system_header_is_displayed(self) -> None: + """Asserts that the Bowel Cancer Screening System header is displayed.""" + expect(self.bowel_cancer_screening_system_header).to_contain_text( + "Bowel Cancer Screening System" + ) + + def main_menu_header_is_displayed(self) -> None: + """ + Asserts that the Main Menu header is displayed. + self.main_menu_string contains the string 'Main Menu' + """ + expect(self.main_menu__header).to_contain_text(self.main_menu_string) + + def bowel_cancer_screening_page_title_contains_text(self, text: str) -> None: + """Asserts that the page title contains the specified text. + + Args: + text (str): The expected text that you want to assert for the page title element. + Page elements of interest: + self.bowel_cancer_screening_page_title + self.bowel_cancer_screening_ntsh_page_title + """ + self.page.wait_for_load_state("load") + self.page.wait_for_load_state("domcontentloaded") + if self.bowel_cancer_screening_page_title.is_visible(): + expect(self.bowel_cancer_screening_page_title).to_contain_text(text) + else: + expect(self.bowel_cancer_screening_ntsh_page_title).to_contain_text(text) + + def go_to_contacts_list_page(self) -> None: + """Click the Base Page 'Contacts List' link.""" + self.click(self.contacts_list_page) + + def go_to_bowel_scope_page(self) -> None: + """Click the Base Page 'Bowel Scope' link.""" + self.click(self.bowel_scope_page) + + def go_to_call_and_recall_page(self) -> None: + """Click the Base Page 'Call and Recall' link.""" + self.click(self.call_and_recall_page) + + def go_to_communications_production_page(self) -> None: + """Click the Base Page 'Communications Production' link.""" + self.click(self.communications_production_page) + + def go_to_download_page(self) -> None: + """Click the Base Page 'Download' link.""" + self.click(self.download_page) + + def go_to_fit_test_kits_page(self) -> None: + """Click the Base Page 'FIT Test Kits' link.""" + self.click(self.fit_test_kits_page) + + def go_to_gfobt_test_kits_page(self) -> None: + """Click the Base Page 'gFOBT Test Kits' link.""" + self.click(self.gfobt_test_kits_page) + + def go_to_lynch_surveillance_page(self) -> None: + """Click the Base Page 'Lynch Surveillance' link.""" + self.click(self.lynch_surveillance_page) + + def go_to_organisations_page(self) -> None: + """Click the Base Page 'Organisations' link.""" + self.click(self.organisations_page) + + def go_to_reports_page(self) -> None: + """Click the Base Page 'Reports' link.""" + self.click(self.reports_page) + + def go_to_screening_practitioner_appointments_page(self) -> None: + """Click the Base Page 'Screening Practitioner Appointments' link.""" + self.click(self.screening_practitioner_appointments_page) + + def go_to_screening_subject_search_page(self) -> None: + """Click the Base Page 'Screening Subject Search' link.""" + self.click(self.screening_subject_search_page) + + def click(self, locator: Locator) -> None: + # Alerts table locator + alerts_table = locator.get_by_role("table", name="cockpitalertbox") + """ + This is used to click on a locator + The reason for this being used over the normal playwright click method is due to: + - BCSS sometimes takes a while to render and so the normal click function 'clicks' on a locator before its available + - Increases the reliability of clicks to avoid issues with the normal click method + """ + if alerts_table.is_visible(): + alerts_table.wait_for(state="attached") + alerts_table.wait_for(state="visible") + + try: + self.page.wait_for_load_state("load") + self.page.wait_for_load_state("domcontentloaded") + self.page.wait_for_load_state("networkidle") + locator.wait_for(state="attached") + locator.wait_for(state="visible") + locator.click() + + except Exception as locatorClickError: + logging.warning( + f"Failed to click element with error: {locatorClickError}, trying again..." + ) + locator.click() + + def _accept_dialog(self, dialog: Dialog) -> None: + """ + This method is used to accept dialogs + If it has already been accepted then it is ignored + """ + try: + dialog.accept() + except Exception: + logging.warning("Dialog already handled") + + def safe_accept_dialog(self, locator: Locator) -> None: + """ + Safely accepts a dialog triggered by a click, avoiding the error: + playwright._impl._errors.Error: Dialog.accept: Cannot accept dialog which is already handled! + If no dialog appears, continues without error. + Args: + locator (Locator): The locator that triggers the dialog when clicked. + example: If clicking a save button opens a dialog, pass that save button's locator. + """ + self.page.once("dialog", self._accept_dialog) + try: + self.click(locator) + except Exception as e: + logging.error(f"Click failed: {e}") + + def assert_dialog_text(self, expected_text: str) -> None: + """ + Asserts that a dialog appears and contains the expected text. + If no dialog appears, logs an error. + Args: + expected_text (str): The text that should be present in the dialog. + """ + self._dialog_assertion_error = None + + def handle_dialog(dialog: Dialog): + logging.info(f"Dialog appeared with message: {dialog.message}") + actual_text = dialog.message + try: + assert ( + actual_text == expected_text + ), f"Expected '{expected_text}', but got '{actual_text}'" + except AssertionError as e: + self._dialog_assertion_error = e + dialog.dismiss() # Dismiss dialog + + self.page.once("dialog", handle_dialog) + + def safe_accept_dialog_select_option(self, locator: Locator, option: str) -> None: + """ + Safely accepts a dialog triggered by selecting a dropdown, avoiding the error: + playwright._impl._errors.Error: Dialog.accept: Cannot accept dialog which is already handled! + If no dialog appears, continues without error. + Args: + locator (Locator): The locator that triggers the dialog when clicked. + example: If clicking a save button opens a dialog, pass that save button's locator. + """ + self.page.once("dialog", self._accept_dialog) + try: + locator.select_option(option) + except Exception as e: + logging.error(f"Option selection failed: {e}") diff --git a/pages/bowel_scope/__init__.py b/pages/bowel_scope/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pages/bowel_scope/bowel_scope_appointments_page.py b/pages/bowel_scope/bowel_scope_appointments_page.py new file mode 100644 index 00000000..f09b3df5 --- /dev/null +++ b/pages/bowel_scope/bowel_scope_appointments_page.py @@ -0,0 +1,17 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class BowelScopeAppointmentsPage(BasePage): + """Bowel Scope Appointments page locators, and methods to interact with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Bowel Scope Appointments - page locators, methods + + def verify_page_title(self) -> None: + """Verifies the page title of the Bowel Scope Appointments page""" + self.bowel_cancer_screening_page_title_contains_text( + "Appointment Calendar" + ) diff --git a/pages/bowel_scope/bowel_scope_page.py b/pages/bowel_scope/bowel_scope_page.py new file mode 100644 index 00000000..ae75c7c1 --- /dev/null +++ b/pages/bowel_scope/bowel_scope_page.py @@ -0,0 +1,18 @@ +from playwright.sync_api import Page +from pages.base_page import BasePage + + +class BowelScopePage(BasePage): + """Bowel Scope page locators, and methods to interact with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Bowel Scope - page locators + self.view_bowel_scope_appointments_page = self.page.get_by_role( + "link", name="View Bowel Scope Appointments" + ) + + def go_to_view_bowel_scope_appointments_page(self) -> None: + """Clicks the link to navigate to the Bowel Scope Appointments page""" + self.click(self.view_bowel_scope_appointments_page) diff --git a/pages/call_and_recall/__init__.py b/pages/call_and_recall/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pages/call_and_recall/age_extension_rollout_plans_page.py b/pages/call_and_recall/age_extension_rollout_plans_page.py new file mode 100644 index 00000000..66a3a29e --- /dev/null +++ b/pages/call_and_recall/age_extension_rollout_plans_page.py @@ -0,0 +1,17 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class AgeExtensionRolloutPlansPage(BasePage): + """Age Extension Rollout Plans page locators, and methods to interact with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Age Extension Rollout Plans - page locators, methods + + def verify_age_extension_rollout_plans_title(self) -> None: + """Verifies the page title of the Age Extension Rollout Plans page""" + self.bowel_cancer_screening_page_title_contains_text( + "Age Extension Rollout Plans" + ) diff --git a/pages/call_and_recall/call_and_recall_page.py b/pages/call_and_recall/call_and_recall_page.py new file mode 100644 index 00000000..2ca2f604 --- /dev/null +++ b/pages/call_and_recall/call_and_recall_page.py @@ -0,0 +1,46 @@ +from playwright.sync_api import Page +from pages.base_page import BasePage + + +class CallAndRecallPage(BasePage): + """Call and Recall page locators, and methods to interact with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Call and Recall - page links + self.planning_and_monitoring_page = self.page.get_by_role( + "link", name="Planning and Monitoring" + ) + self.generate_invitations_page = self.page.get_by_role( + "link", name="Generate Invitations" + ) + self.invitation_generation_progress_page = self.page.get_by_role( + "link", name="Invitation Generation Progress" + ) + self.non_invitation_days_page = self.page.get_by_role( + "link", name="Non Invitation Days" + ) + self.age_extension_rollout_plans_page = self.page.get_by_role( + "link", name="Age Extension Rollout Plans" + ) + + def go_to_planning_and_monitoring_page(self) -> None: + """Clicks the link to navigate to the Planning and Monitoring page""" + self.click(self.planning_and_monitoring_page) + + def go_to_generate_invitations_page(self) -> None: + """Clicks the link to navigate to the Generate Invitations page""" + self.click(self.generate_invitations_page) + + def go_to_invitation_generation_progress_page(self) -> None: + """Clicks the link to navigate to the Invitation Generation Progress page""" + self.click(self.invitation_generation_progress_page) + + def go_to_non_invitation_days_page(self) -> None: + """Clicks the link to navigate to the Non Invitation Days page""" + self.click(self.non_invitation_days_page) + + def go_to_age_extension_rollout_plans_page(self) -> None: + """Clicks the link to navigate to the Age Extension Rollout Plans page""" + self.click(self.age_extension_rollout_plans_page) diff --git a/pages/call_and_recall/create_a_plan_page.py b/pages/call_and_recall/create_a_plan_page.py new file mode 100644 index 00000000..66e61b3d --- /dev/null +++ b/pages/call_and_recall/create_a_plan_page.py @@ -0,0 +1,179 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage +from utils.table_util import TableUtils + + +class CreateAPlanPage(BasePage): + """Create a Plan page locators and methods to interact with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Call and Recall - page links + self.set_all_button = self.page.get_by_role("link", name="Set all") + self.daily_invitation_rate_field = self.page.get_by_placeholder( + "Enter daily invitation rate" + ) + self.weekly_invitation_rate_field = self.page.get_by_placeholder( + "Enter weekly invitation rate" + ) + self.update_button = self.page.get_by_role("button", name="Update") + self.confirm_button = self.page.get_by_role("button", name="Confirm") + self.save_button = self.page.get_by_role("button", name="Save") + self.note_field = self.page.get_by_placeholder("Enter note") + self.save_note_button = self.page.locator("#saveNote").get_by_role( + "button", name="Save" + ) + # Create A Plan Table Locators + self.weekly_invitation_rate_field_on_table = self.page.locator( + "#invitationPlan > tbody > tr:nth-child(1) > td.input.border-right.dt-type-numeric > input" + ) + self.invitations_sent_value = self.page.locator( + "tbody tr:nth-child(1) td:nth-child(8)" + ) + + self.resulting_position_value = self.page.locator( + "#invitationPlan > tbody > tr:nth-child(1) > td:nth-child(9)" + ) + + # Initialize TableUtils for different tables + self.create_a_plan_table = TableUtils(page, "#invitationPlan") + + def click_set_all_button(self) -> None: + """Clicks the Set all button to set all values""" + self.click(self.set_all_button) + + def fill_daily_invitation_rate_field(self, value: str) -> None: + """Fills the daily invitation rate field with the given value""" + self.daily_invitation_rate_field.fill(value) + + def fill_weekly_invitation_rate_field(self, value) -> None: + """Fills the weekly invitation rate field with the given value""" + self.weekly_invitation_rate_field.fill(value) + + def click_update_button(self) -> None: + """Clicks the Update button to save any changes""" + self.click(self.update_button) + + def click_confirm_button(self) -> None: + """Clicks the Confirm button""" + self.click(self.confirm_button) + + def click_save_button(self) -> None: + """Clicks the Save button""" + self.click(self.save_button) + + def fill_note_field(self, value) -> None: + """Fills the note field with the given value""" + self.note_field.fill(value) + + def click_save_note_button(self) -> None: + """Clicks the Save note button""" + self.click(self.save_note_button) + + def verify_create_a_plan_title(self) -> None: + """Verifies the Create a Plan page title""" + self.bowel_cancer_screening_page_title_contains_text("View a plan") + + def verify_weekly_invitation_rate_for_weeks( + self, start_week: int, end_week: int, expected_weekly_rate: str + ) -> None: + """ + Verifies that the weekly invitation rate is correctly calculated and displayed for the specified range of weeks. + + Args: + start_week (int): The starting week of the range. + end_week (int): The ending week of the range. + expected_weekly_rate (str): The expected weekly invitation rate. + """ + + # Verify the rate for the starting week + weekly_invitation_rate_selector = "#invitationPlan > tbody > tr:nth-child(2) > td.input.border-right.dt-type-numeric > input" + self.page.wait_for_selector(weekly_invitation_rate_selector) + weekly_invitation_rate = self.page.locator( + weekly_invitation_rate_selector + ).input_value() + + assert ( + weekly_invitation_rate == expected_weekly_rate + ), f"Expected weekly invitation rate '{expected_weekly_rate}' for week {start_week} but got '{weekly_invitation_rate}'" + # Verify the rate for the specified range of weeks + for week in range(start_week + 1, end_week + 1): + weekly_rate_locator = f"#invitationPlan > tbody > tr:nth-child({week + 2}) > td.input.border-right.dt-type-numeric > input" + + # Wait for the element to be available + self.page.wait_for_selector(weekly_rate_locator) + + # Get the input value safely + weekly_rate_element = self.page.locator(weekly_rate_locator) + assert ( + weekly_rate_element.is_visible() + ), f"Week {week} rate element not visible" + + # Verify the value + actual_weekly_rate = weekly_rate_element.input_value() + assert ( + actual_weekly_rate == expected_weekly_rate + ), f"Week {week} invitation rate should be '{expected_weekly_rate}', but found '{actual_weekly_rate}'" + + # Get the text safely + # Get the frame first + frame = self.page.frame( + url="https://bcss-bcss-18680-ddc-bcss.k8s-nonprod.texasplatform.uk/invitation/plan/23159/23162/create" + ) + + # Ensure the frame is found before proceeding + assert frame, "Frame not found!" + + # Now locate the input field inside the frame and get its value + weekly_invitation_rate_selector = "#invitationPlan > tbody > tr:nth-child(2) > td.input.border-right.dt-type-numeric > input" + weekly_invitation_rate = frame.locator( + weekly_invitation_rate_selector + ).input_value() + + # Assert the expected value + assert ( + weekly_invitation_rate == expected_weekly_rate + ), f"Week 2 invitation rate should be '{expected_weekly_rate}', but found '{weekly_invitation_rate}'" + + def increment_invitation_rate_and_verify_changes(self) -> None: + """ + Increments the invitation rate by 1, then verifies that both the + 'Invitations Sent' has increased by 1 and 'Resulting Position' has decreased by 1. + """ + # Capture initial values before any changes + initial_invitations_sent = int(self.invitations_sent_value.inner_text().strip()) + initial_resulting_position = int( + self.resulting_position_value.inner_text().strip() + ) + + # Increment the invitation rate + current_rate = int( + self.create_a_plan_table.get_cell_value("Invitation Rate", 1) + ) + new_rate = str(current_rate + 1) + self.weekly_invitation_rate_field_on_table.fill(new_rate) + self.page.keyboard.press("Tab") + + # Wait dynamically for updates + expect(self.invitations_sent_value).to_have_text( + str(initial_invitations_sent + 1) + ) + expect(self.resulting_position_value).to_have_text( + str(initial_resulting_position + 1) + ) + + # Capture updated values + updated_invitations_sent = int(self.invitations_sent_value.inner_text().strip()) + updated_resulting_position = int( + self.resulting_position_value.inner_text().strip() + ) + + # Assert changes + assert ( + updated_invitations_sent == initial_invitations_sent + 1 + ), f"Expected Invitations Sent to increase by 1. Was {initial_invitations_sent}, now {updated_invitations_sent}." + + assert ( + updated_resulting_position == initial_resulting_position + 1 + ), f"Expected Resulting Position to increase by 1. Was {initial_resulting_position}, now {updated_resulting_position}." diff --git a/pages/call_and_recall/generate_invitations_page.py b/pages/call_and_recall/generate_invitations_page.py new file mode 100644 index 00000000..5b9d9a88 --- /dev/null +++ b/pages/call_and_recall/generate_invitations_page.py @@ -0,0 +1,105 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage +import pytest +import logging + + +class GenerateInvitationsPage(BasePage): + """Generate Invitations page locators, and methods to interact with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Generate Invitations - page links + self.generate_invitations_button = self.page.get_by_role( + "button", name="Generate Invitations" + ) + self.display_rs = self.page.locator("#displayRS") + self.refresh_button = self.page.get_by_role("button", name="Refresh") + self.planned_invitations_total = self.page.locator("#col8_total") + self.self_referrals_total = self.page.locator("#col9_total") + + def click_generate_invitations_button(self) -> None: + """This function is used to click the Generate Invitations button.""" + self.click(self.generate_invitations_button) + + def click_refresh_button(self) -> None: + """This function is used to click the Refresh button.""" + self.click(self.refresh_button) + + def verify_generate_invitations_title(self) -> None: + """This function is used to verify the Generate Invitations page title.""" + self.bowel_cancer_screening_page_title_contains_text("Generate Invitations") + + def verify_invitation_generation_progress_title(self) -> None: + """This function is used to verify the Invitation Generation Progress page title.""" + self.bowel_cancer_screening_page_title_contains_text( + "Invitation Generation Progress" + ) + + def wait_for_invitation_generation_complete( + self, number_of_invitations: int + ) -> bool: + """ + This function is used to wait for the invitations to be generated. + Every 5 seconds it refreshes the table and checks to see if the invitations have been generated. + It also checks that enough invitations were generated and checks to see if self referrals are present + """ + self.page.wait_for_selector("#displayRS", timeout=5000) + + if self.planned_invitations_total == "0": + pytest.fail("There are no planned invitations to generate") + + # Initially, ensure the table contains "Queued" + expect(self.display_rs).to_contain_text("Queued") + + # Set timeout parameters + timeout = 120000 # Total timeout of 120 seconds (in milliseconds) + wait_interval = 5000 # Wait 5 seconds between refreshes (in milliseconds) + elapsed = 0 + + # Loop until the table no longer contains "Queued" + logging.info("Waiting for successful generation") + while ( + elapsed < timeout + ): # there may be a stored procedure to speed this process up + table_text = self.display_rs.text_content() + if table_text is None: + pytest.fail("Failed to retrieve table text content") + + if "Failed" in table_text: + pytest.fail("Invitation has failed to generate") + elif "Queued" in table_text or "In Progress" in table_text: + # Click the Refresh button + self.click_refresh_button() + self.page.wait_for_timeout(wait_interval) + elapsed += wait_interval + else: + break + + # Final check: ensure that the table now contains "Completed" + try: + expect(self.display_rs).to_contain_text("Completed") + logging.info("Invitations successfully generated") + logging.info(f"Invitations took {elapsed / 1000} seconds to generate") + except Exception as e: + pytest.fail(f"Invitations not generated successfully: {str(e)}") + + value = self.planned_invitations_total.text_content() + if value is None: + pytest.fail("Failed to retrieve planned invitations total") + value = value.strip() # Get text and remove extra spaces + if int(value) < number_of_invitations: + pytest.fail( + f"Expected {number_of_invitations} invitations generated but got {value}" + ) + + self_referrals_total_text = self.self_referrals_total.text_content() + if self_referrals_total_text is None: + pytest.fail("Failed to retrieve self-referrals total") + self_referrals_total = int(self_referrals_total_text.strip()) + if self_referrals_total >= 1: + return True + else: + logging.warning("No S1 Digital Leaflet batch will be generated") + return False diff --git a/pages/call_and_recall/invitations_monitoring_page.py b/pages/call_and_recall/invitations_monitoring_page.py new file mode 100644 index 00000000..439c7fb9 --- /dev/null +++ b/pages/call_and_recall/invitations_monitoring_page.py @@ -0,0 +1,18 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class InvitationsMonitoringPage(BasePage): + """Invitations Monitoring page locators, and methods to interact with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + + def go_to_invitation_plan_page(self, sc_id) -> None: + self.click(self.page.get_by_role("link", name=sc_id)) + + def verify_invitations_monitoring_title(self) -> None: + self.bowel_cancer_screening_page_title_contains_text( + "Invitations Monitoring - Screening Centre" + ) diff --git a/pages/call_and_recall/invitations_plans_page.py b/pages/call_and_recall/invitations_plans_page.py new file mode 100644 index 00000000..f30ca1d7 --- /dev/null +++ b/pages/call_and_recall/invitations_plans_page.py @@ -0,0 +1,26 @@ +from playwright.sync_api import Page +from pages.base_page import BasePage + + +class InvitationsPlansPage(BasePage): + """Invitations Plans page locators, and methods to interact with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Call and Recall - page links + self.create_a_plan = self.page.get_by_role("button", name="Create a Plan") + self.invitations_plans_title = self.page.locator( + '#page-title:has-text("Invitation Plans")' + ) + self.first_available_plan = ( + self.page.get_by_role("row").nth(1).get_by_role("link") + ) + + def go_to_create_a_plan_page(self) -> None: + """Clicks the Create a Plan button to navigate to the Create a Plan page""" + self.click(self.create_a_plan) + + def go_to_first_available_plan(self) -> None: + """Clicks the first available plan to navigate to the Create a Plan page""" + self.click(self.first_available_plan) diff --git a/pages/call_and_recall/non_invitations_days_page.py b/pages/call_and_recall/non_invitations_days_page.py new file mode 100644 index 00000000..591cc853 --- /dev/null +++ b/pages/call_and_recall/non_invitations_days_page.py @@ -0,0 +1,63 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage +from utils.date_time_utils import DateTimeUtils + + +class NonInvitationDaysPage(BasePage): + """Non Invitation Days page locators, and methods to interact with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Non Invitation Days - page locators, methods + self.enter_note_field = self.page.locator("#note") + self.enter_date_field = self.page.get_by_role("textbox", name="date") + self.add_non_invitation_day_button = self.page.get_by_role( + "button", name="Add Non-Invitation Day" + ) + self.non_invitation_day_delete_button = self.page.get_by_role( + "button", name="Delete" + ) + self.created_on_date_locator = self.page.locator( + "tr.oddTableRow td:nth-child(4)" + ) + + def verify_non_invitation_days_tile(self) -> None: + """Verifies the page title of the Non Invitation Days page""" + self.bowel_cancer_screening_page_title_contains_text("Non-Invitation Days") + + def enter_date(self, date: str) -> None: + """Enters a date in the date input field + Args: + date (str): The date to enter in the field, formatted as 'dd/mm/yyyy'. + """ + self.enter_date_field.fill(date) + + def enter_note(self, note: str) -> None: + """Enters a note in the note input field + Args: + note (str): The note to enter in the field. + """ + self.enter_note_field.fill(note) + + def click_add_non_invitation_day_button(self) -> None: + """Clicks the Add Non-Invitation Day button""" + self.click(self.add_non_invitation_day_button) + + def click_delete_button(self) -> None: + """Clicks the Delete button for a non-invitation day""" + self.click(self.non_invitation_day_delete_button) + + def verify_created_on_date_is_visible(self) -> None: + """Verifies that the specified date is visible on the page + Args: + date (str): The date to verify, formatted as 'dd/mm/yyyy'. + """ + today = DateTimeUtils.current_datetime("%d/%m/%Y") + expect(self.created_on_date_locator).to_have_text(today) + + def verify_created_on_date_is_not_visible(self) -> None: + """Verifies that the 'created on' date element is not visible on the page. + This is used to confirm that the non-invitation day has been successfully deleted. + """ + expect(self.created_on_date_locator).not_to_be_visible diff --git a/pages/communication_production/__init__.py b/pages/communication_production/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pages/communication_production/batch_list_page.py b/pages/communication_production/batch_list_page.py new file mode 100644 index 00000000..cdd5d4d9 --- /dev/null +++ b/pages/communication_production/batch_list_page.py @@ -0,0 +1,135 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage +from datetime import datetime +from utils.calendar_picker import CalendarPicker + + +class BatchListPage(BasePage): + """Batch List Page locators, and methods for interacting with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Batch List - page filters + self.id_filter = self.page.locator("#batchIdFilter") + self.type_filter = self.page.locator("#batchTypeFilter") + self.original_filter = self.page.locator("#originalBatchIdFilter") + self.event_code_filter = self.page.locator("#eventCodeFilter") + self.description_filter = self.page.locator("#eventDescriptionFilter") + self.batch_split_by_filter = self.page.locator("#splitByFilter") + self.screening_centre_filter = self.page.locator("#screeningCentreFilter") + self.count_filter = self.page.locator("#countFilter") + self.table_data = self.page.locator("td") + self.batch_successfully_archived_msg = self.page.locator( + 'text="Batch Successfully Archived and Printed"' + ) + self.deadline_calendar_picker = self.page.locator("i") + self.deadline_date_filter = self.page.get_by_role("cell", name="").get_by_role( + "textbox" + ) + self.deadline_date_filter_with_input = self.page.locator( + "input.form-control.filter.filtering" + ) + self.deadline_date_clear_button = self.page.get_by_role("cell", name="Clear") + + def verify_batch_list_page_title(self, text) -> None: + """Verify the Batch List page title is displayed as expected""" + self.bowel_cancer_screening_page_title_contains_text(text) + + def verify_table_data(self, value) -> None: + """Verify the table data is displayed as expected""" + expect(self.table_data.filter(has_text=value)).to_be_visible() + + def enter_id_filter(self, search_text: str) -> None: + """Enter text in the ID filter and press Enter""" + self.id_filter.fill(search_text) + self.id_filter.press("Enter") + + def enter_type_filter(self, search_text: str) -> None: + """Enter text in the Type filter and press Enter""" + self.type_filter.fill(search_text) + self.type_filter.press("Enter") + + def enter_original_filter(self, search_text: str) -> None: + """Enter text in the Original filter and press Enter""" + self.original_filter.fill(search_text) + self.original_filter.press("Enter") + + def enter_event_code_filter(self, search_text: str) -> None: + """Enter text in the Event Code filter and press Enter""" + self.event_code_filter.fill(search_text) + self.event_code_filter.press("Enter") + + def enter_description_filter(self, search_text: str) -> None: + """Enter text in the Description filter and press Enter""" + self.description_filter.fill(search_text) + self.description_filter.press("Enter") + + def enter_batch_split_by_filter(self, search_text: str) -> None: + """Enter text in the 'Batch Split By' filter and press Enter""" + self.batch_split_by_filter.fill(search_text) + self.batch_split_by_filter.press("Enter") + + def enter_screening_centre_filter(self, search_text: str) -> None: + """Enter text in the Screening Centre filter and press Enter""" + self.screening_centre_filter.fill(search_text) + self.screening_centre_filter.press("Enter") + + def enter_count_filter(self, search_text: str) -> None: + """Enter text in the Count filter and press Enter""" + self.count_filter.fill(search_text) + self.count_filter.press("Enter") + + def enter_deadline_date_filter(self, date: datetime) -> None: + """Enter a date in the Deadline Date filter and press Enter""" + self.click(self.deadline_calendar_picker) + CalendarPicker(self.page).v2_calendar_picker(date) + + def clear_deadline_filter_date(self) -> None: + """Clear the date in the Deadline Date filter""" + self.click(self.deadline_calendar_picker) + self.click(self.deadline_date_clear_button) + + def verify_deadline_date_filter_input(self, expected_text: str) -> None: + expect(self.deadline_date_filter_with_input).to_have_value(expected_text) + + def open_letter_batch( + self, batch_type: str, status: str, level: str, description: str + ) -> None: + """ + Finds and opens the batch row based on type, status, level, and description. + Args: + batch_type (str): The type of the batch (e.g., "Original"). + status (str): The status of the batch (e.g., "Open"). + level (str): The level of the batch (e.g., "S1"). + description (str): The description of the batch (e.g., "Pre-invitation (FIT)"). + """ + # Step 1: Match the row using nested filters, one per column value + row = ( + self.page.locator("table tbody tr") + .filter(has=self.page.locator("td", has_text=batch_type)) + .filter(has=self.page.locator("td", has_text=status)) + .filter(has=self.page.locator("td", has_text=level)) + .filter(has=self.page.locator("td", has_text=description)) + ) + + # Step 2: Click the "View" link in the matched row + view_link = row.locator( + "a" + ) # Click the first link in the row identified in step 1 + expect(view_link).to_be_visible() + view_link.click() + + +class ActiveBatchListPage(BatchListPage): + """Active Batch List Page locators, and methods for interacting with the Active Batch List page""" + + def __init__(self, page): + super().__init__(page) + + +class ArchivedBatchListPage(BatchListPage): + """Archived Batch List Page locators, and methods for interacting with the Archived Batch List page""" + + def __init__(self, page): + super().__init__(page) diff --git a/pages/communication_production/communications_production_page.py b/pages/communication_production/communications_production_page.py new file mode 100644 index 00000000..472111cf --- /dev/null +++ b/pages/communication_production/communications_production_page.py @@ -0,0 +1,53 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class CommunicationsProductionPage(BasePage): + """Communications Production Page locators, and methods for interacting with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Communication Production - page links + self.active_batch_list_page = self.page.get_by_role( + "link", name="Active Batch List" + ) + self.archived_batch_list_page = self.page.get_by_role( + "link", name="Archived Batch List" + ) + self.letter_library_index_page = self.page.get_by_role( + "link", name="Letter Library Index" + ) + self.letter_signatory_page = self.page.get_by_role( + "link", name="Letter Signatory" + ) + self.electronic_communication_management_page = self.page.get_by_role( + "link", name="Electronic Communication Management" + ) + self.manage_individual_letter_page = self.page.get_by_text( + "Manage Individual Letter" + ) + + def verify_manage_individual_letter_page_visible(self) -> None: + """Verify the Manage Individual Letter page is visible""" + expect(self.manage_individual_letter_page).to_be_visible() + + def go_to_active_batch_list_page(self) -> None: + """Navigate to the Active Batch List page""" + self.click(self.active_batch_list_page) + + def go_to_archived_batch_list_page(self) -> None: + """Navigate to the Archived Batch List page""" + self.click(self.archived_batch_list_page) + + def go_to_letter_library_index_page(self) -> None: + """Navigate to the Letter Library Index page""" + self.click(self.letter_library_index_page) + + def go_to_letter_signatory_page(self) -> None: + """Navigate to the Letter Signatory page""" + self.click(self.letter_signatory_page) + + def go_to_electronic_communication_management_page(self) -> None: + """Navigate to the Electronic Communication Management page""" + self.click(self.electronic_communication_management_page) diff --git a/pages/communication_production/electronic_communications_management_page.py b/pages/communication_production/electronic_communications_management_page.py new file mode 100644 index 00000000..bc0ae4de --- /dev/null +++ b/pages/communication_production/electronic_communications_management_page.py @@ -0,0 +1,17 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class ElectronicCommunicationManagementPage(BasePage): + """Electronic Communication Management Page locators, and methods for interacting with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Electronic Communication Management - page locators, methods + + def verify_electronic_communication_management_title(self) -> None: + """Verify the Electronic Communication Management page title is displayed as expected""" + self.bowel_cancer_screening_page_title_contains_text( + "Electronic Communication Management" + ) diff --git a/pages/communication_production/letter_library_index_page.py b/pages/communication_production/letter_library_index_page.py new file mode 100644 index 00000000..07c6fc92 --- /dev/null +++ b/pages/communication_production/letter_library_index_page.py @@ -0,0 +1,15 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class LetterLibraryIndexPage(BasePage): + """Letter Library Index Page locators, and methods for interacting with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Letter Library Index - page locators, methods + + def verify_letter_library_index_title(self) -> None: + """Verify the Letter Library Index page title is displayed as expected""" + self.bowel_cancer_screening_page_title_contains_text("Letter Library Index") diff --git a/pages/communication_production/letter_signatory_page.py b/pages/communication_production/letter_signatory_page.py new file mode 100644 index 00000000..4f627002 --- /dev/null +++ b/pages/communication_production/letter_signatory_page.py @@ -0,0 +1,17 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class LetterSignatoryPage(BasePage): + """Letter Signatory Page locators, and methods for interacting with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Letter Signatory - page locators, methods + + def verify_letter_signatory_title(self) -> None: + """Verify the Letter Signatory page title is displayed as expected""" + self.bowel_cancer_screening_page_title_contains_text( + "Letter Signatory" + ) diff --git a/pages/communication_production/manage_active_batch_page.py b/pages/communication_production/manage_active_batch_page.py new file mode 100644 index 00000000..70155062 --- /dev/null +++ b/pages/communication_production/manage_active_batch_page.py @@ -0,0 +1,31 @@ +from playwright.sync_api import Page +from pages.base_page import BasePage + + +class ManageActiveBatchPage(BasePage): + """Manage Active Batch Page locators, and methods for interacting with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Manage Active Batch - page buttons + self.prepare_button = self.page.get_by_role("button", name="Prepare Batch") + self.retrieve_button = self.page.get_by_role("button", name="Retrieve") + self.confirm_button = self.page.get_by_role("button", name="Confirm Printed") + # Manage Active Batch - page buttons (text) + self.prepare_button_text = self.page.locator('text="Prepare Batch"') + self.retrieve_button_text = self.page.locator('text="Retrieve"') + self.confirm_button_text = self.page.locator('text="Confirm Printed"') + self.reprepare_batch_text = self.page.locator('text="Re-Prepare Batch"') + + def click_prepare_button(self) -> None: + """Click the Prepare Batch button""" + self.click(self.prepare_button) + + def click_retrieve_button(self) -> None: + """Click the Retrieve button""" + self.click(self.retrieve_button) + + def click_confirm_button(self) -> None: + """Click the Confirm Printed button""" + self.click(self.confirm_button) diff --git a/pages/contacts_list/__init__.py b/pages/contacts_list/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pages/contacts_list/contacts_list_page.py b/pages/contacts_list/contacts_list_page.py new file mode 100644 index 00000000..745044a3 --- /dev/null +++ b/pages/contacts_list/contacts_list_page.py @@ -0,0 +1,51 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class ContactsListPage(BasePage): + """Contacts List Page locators, and methods for interacting with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # ContactsList Page + self.view_contacts_page = self.page.get_by_role("link", name="View Contacts") + self.edit_my_contact_details_page = self.page.get_by_role( + "link", name="Edit My Contact Details" + ) + self.maintain_contacts_page = self.page.get_by_role( + "link", name="Maintain Contacts" + ) + self.my_preference_settings_page = self.page.get_by_role( + "link", name="My Preference Settings" + ) + self.extract_contact_details_page = self.page.get_by_text( + "Extract Contact Details" + ) + self.resect_and_discard_accredited_page = self.page.get_by_text( + "Resect and Discard Accredited" + ) + + def go_to_view_contacts_page(self) -> None: + """Click the View Contacts link""" + self.click(self.view_contacts_page) + + def go_to_edit_my_contact_details_page(self) -> None: + """Click the Edit My Contact Details link""" + self.click(self.edit_my_contact_details_page) + + def go_to_maintain_contacts_page(self) -> None: + """Click the Maintain Contacts link""" + self.click(self.maintain_contacts_page) + + def go_to_my_preference_settings_page(self) -> None: + """Click the My Preference Settings link""" + self.click(self.my_preference_settings_page) + + def verify_extract_contact_details_page_visible(self) -> None: + """Verify the Extract Contact Details page is visible""" + expect(self.extract_contact_details_page).to_be_visible() + + def verify_resect_and_discard_accredited_page_visible(self) -> None: + """Verify the Resect and Discard Accredited page is visible""" + expect(self.resect_and_discard_accredited_page).to_be_visible() diff --git a/pages/contacts_list/edit_my_contact_details_page.py b/pages/contacts_list/edit_my_contact_details_page.py new file mode 100644 index 00000000..3a62a83f --- /dev/null +++ b/pages/contacts_list/edit_my_contact_details_page.py @@ -0,0 +1,15 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class EditMyContactDetailsPage(BasePage): + """Edit My Contact Details Page locators, and methods for interacting with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Edit My Contact Details - page locators, methods + + def verify_edit_my_contact_details_title(self) -> None: + """Verify the Edit My Contact Details page title is displayed correctly""" + self.bowel_cancer_screening_page_title_contains_text("Edit My Contact Details") diff --git a/pages/contacts_list/maintain_contacts_page.py b/pages/contacts_list/maintain_contacts_page.py new file mode 100644 index 00000000..4a03df05 --- /dev/null +++ b/pages/contacts_list/maintain_contacts_page.py @@ -0,0 +1,15 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class MaintainContactsPage(BasePage): + """Maintain Contacts Page locators, and methods for interacting with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Maintain Contacts - page locators, methods + + def verify_maintain_contacts_title(self) -> None: + """Verify the Maintain Contacts page title is displayed correctly""" + self.bowel_cancer_screening_page_title_contains_text("Maintain Contacts") diff --git a/pages/contacts_list/my_preference_settings_page.py b/pages/contacts_list/my_preference_settings_page.py new file mode 100644 index 00000000..ae21ddca --- /dev/null +++ b/pages/contacts_list/my_preference_settings_page.py @@ -0,0 +1,17 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class MyPreferenceSettingsPage(BasePage): + """My Preference Settings Page locators, and methods for interacting with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # My Preference Settings - page locators, methods + + def verify_my_preference_settings_title(self) -> None: + """Verify the My Preference Settings page title is displayed correctly""" + self.bowel_cancer_screening_page_title_contains_text( + "My Preference Settings" + ) diff --git a/pages/contacts_list/view_contacts_page.py b/pages/contacts_list/view_contacts_page.py new file mode 100644 index 00000000..a23c4d27 --- /dev/null +++ b/pages/contacts_list/view_contacts_page.py @@ -0,0 +1,17 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class ViewContactsPage(BasePage): + """View Contacts Page locators, and methods for interacting with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # View Contacts - page locators, methods + + def verify_view_contacts_title(self) -> None: + """Verify the View Contacts page title is displayed correctly""" + self.bowel_cancer_screening_page_title_contains_text( + "View Contacts" + ) diff --git a/pages/datasets/colonoscopy_dataset_page.py b/pages/datasets/colonoscopy_dataset_page.py new file mode 100644 index 00000000..87fea233 --- /dev/null +++ b/pages/datasets/colonoscopy_dataset_page.py @@ -0,0 +1,80 @@ +from playwright.sync_api import Page +from pages.base_page import BasePage +from enum import Enum + + +class ColonoscopyDatasetsPage(BasePage): + """Colonoscopy Datasets Page locators, and methods for interacting with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + + # Colonoscopy datasets page locators + self.save_dataset_button = self.page.locator( + "#UI_DIV_BUTTON_SAVE1" + ).get_by_role("button", name="Save Dataset") + + self.select_asa_grade_dropdown = self.page.get_by_label("ASA Grade") + + self.select_fit_for_colonoscopy_dropdown = self.page.get_by_label( + "Fit for Colonoscopy (SSP)" + ) + + self.dataset_complete_radio_button_yes = self.page.get_by_role( + "radio", name="Yes" + ) + + self.dataset_complete_radio_button_no = self.page.get_by_role( + "radio", name="No" + ) + + def save_dataset(self) -> None: + """Clicks the Save Dataset button on the colonoscopy datasets page.""" + self.click(self.save_dataset_button) + + def select_asa_grade_option(self, option: str) -> None: + """ + This method is designed to select a specific grade option from the colonoscopy dataset page, ASA Grade dropdown menu. + Args: + option (str): The ASA grade option to be selected. This should be a string that matches one of the available options in the dropdown menu. + Valid options are: "FIT", "RELEVANT_DISEASE", "UNABLE_TO_ASSESS", RESTRICTIVE_DISEASE, "LIFE_THREATENING_DISEASE", "MORIBUND", "NOT_APPLICABLE", or "NOT_KNOWN". + Returns: + None + """ + self.select_asa_grade_dropdown.select_option(option) + + def select_fit_for_colonoscopy_option(self, option: str) -> None: + """ + This method is designed to select a specific option from the colonoscopy dataset page, Fit for Colonoscopy (SSP) dropdown menu. + Args: + option (str): The option to be selected. This should be a string that matches one of the available options in the dropdown menu. + Valid options are: "YES", "NO", or "UNABLE_TO_ASSESS". + Returns: + None + """ + self.select_fit_for_colonoscopy_dropdown.select_option(option) + + def click_dataset_complete_radio_button_yes(self) -> None: + """Clicks the 'Yes' radio button for the dataset complete option.""" + self.dataset_complete_radio_button_yes.check() + + def click_dataset_complete_radio_button_no(self) -> None: + """Clicks the 'No' radio button for the dataset complete option.""" + self.dataset_complete_radio_button_no.check() + + +class AsaGradeOptions(Enum): + FIT = "17009" + RELEVANT_DISEASE = "17010" + RESTRICTIVE_DISEASE = "17011" + LIFE_THREATENING_DISEASE = "17012" + MORIBUND = "17013" + NOT_APPLICABLE = "17014" + NOT_KNOWN = "17015" + + +class FitForColonoscopySspOptions(Enum): + YES = "17058" + NO = "17059" + UNABLE_TO_ASSESS = "17954" diff --git a/pages/datasets/investigation_dataset_page.py b/pages/datasets/investigation_dataset_page.py new file mode 100644 index 00000000..82459bad --- /dev/null +++ b/pages/datasets/investigation_dataset_page.py @@ -0,0 +1,437 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage +from enum import StrEnum + + +class InvestigationDatasetsPage(BasePage): + """Investigation Datasets Page locators, and methods for interacting with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + + # Investigation datasets page locators + self.site_lookup_link = self.page.locator("#UI_SITE_SELECT_LINK") + self.practitioner_link = self.page.locator("#UI_SSP_PIO_SELECT_LINK") + self.testing_clinician_link = self.page.locator( + "#UI_CONSULTANT_PIO_SELECT_LINK" + ) + self.aspirant_endoscopist_link = self.page.locator( + "#UI_ASPIRANT_ENDOSCOPIST_PIO_SELECT_LINK" + ) + self.show_drug_information_detail = self.page.locator("#anchorDrug") + self.drug_type_option1 = self.page.locator("#UI_BOWEL_PREP_DRUG1") + self.drug_type_dose1 = self.page.locator("#UI_BOWEL_PREP_DRUG_DOSE1") + self.show_endoscopy_information_details = self.page.locator( + "#anchorColonoscopy" + ) + self.endoscope_inserted_yes = self.page.locator("#radScopeInsertedYes") + self.therapeutic_procedure_type = self.page.get_by_role( + "radio", name="Therapeutic" + ) + self.diagnostic_procedure_type = self.page.get_by_role( + "radio", name="Diagnostic" + ) + self.show_completion_proof_information_details = self.page.locator( + "#anchorCompletionProof" + ) + self.show_failure_information_details = self.page.locator("#anchorFailure") + self.add_polyp_button = self.page.get_by_role("button", name="Add Polyp") + self.polyp1_add_intervention_button = self.page.get_by_role( + "link", name="Add Intervention" + ) + self.polyp2_add_intervention_button = self.page.locator( + "#spanPolypInterventionLink2" + ).get_by_role("link", name="Add Intervention") + self.dataset_complete_checkbox = self.page.locator("#radDatasetCompleteYes") + self.save_dataset_button = self.page.locator( + "#UI_DIV_BUTTON_SAVE1" + ).get_by_role("button", name="Save Dataset") + + def select_site_lookup_option(self, option: str) -> None: + """ + This method is designed to select a site from the site lookup options. + It clicks on the site lookup link and selects the given option. + + Args: + option (str): The option to select from the aspirant endoscopist options. + """ + self.click(self.site_lookup_link) + option_locator = self.page.locator(f'[value="{option}"]:visible') + option_locator.wait_for(state="visible") + self.click(option_locator) + + def select_practitioner_option(self, option: str) -> None: + """ + This method is designed to select a practitioner from the practitioner options. + It clicks on the practitioner link and selects the given option. + + Args: + option (str): The option to select from the aspirant endoscopist options. + """ + self.click(self.practitioner_link) + option_locator = self.page.locator(f'[value="{option}"]:visible') + option_locator.wait_for(state="visible") + self.click(option_locator) + + def select_testing_clinician_option(self, option: str) -> None: + """ + This method is designed to select a testing clinician from the testing clinician options. + It clicks on the testing clinician link and selects the given option. + + Args: + option (str): The option to select from the aspirant endoscopist options. + """ + self.click(self.testing_clinician_link) + option_locator = self.page.locator(f'[value="{option}"]:visible') + option_locator.wait_for(state="visible") + self.click(option_locator) + + def select_aspirant_endoscopist_option(self, option: str) -> None: + """ + This method is designed to select an aspirant endoscopist from the aspirant endoscopist options. + It clicks on the aspirant endoscopist link and selects the given option. + + Args: + option (str): The option to select from the aspirant endoscopist options. + """ + self.click(self.aspirant_endoscopist_link) + option_locator = self.page.locator(f'[value="{option}"]:visible') + option_locator.wait_for(state="visible") + self.click(option_locator) + + def click_show_drug_information(self) -> None: + """ + This method is designed to click on the show drug information link. + It clicks on the show drug information link. + """ + self.click(self.show_drug_information_detail) + + def select_drug_type_option1(self, option: str) -> None: + """ + This method is designed to select a drug type from the first drug type options. + It clicks on the drug type option and selects the given option. + + Args: + option (str): The option to select from the aspirant endoscopist options. + """ + self.click(self.drug_type_option1) + self.drug_type_option1.select_option(option) + + def fill_drug_type_dose1(self, dose: str) -> None: + """ + This method is designed to fill in the drug type dose for the first drug type options. + It fills in the given dose. + + Args: + dose (str): The dose to fill in for the drug type. + """ + self.click(self.drug_type_dose1) + self.drug_type_dose1.fill(dose) + + def click_show_endoscopy_information(self) -> None: + """ + This method is designed to click on the show endoscopy information link. + It clicks on the show endoscopy information link. + """ + self.click(self.show_endoscopy_information_details) + + def check_endoscope_inserted_yes(self) -> None: + """ + This method is designed to check the endoscope inserted yes option. + It checks the endoscope inserted yes option. + """ + self.endoscope_inserted_yes.check() + + def select_therapeutic_procedure_type(self) -> None: + """ + This method is designed to select the therapeutic procedure type. + It selects the therapeutic procedure type. + """ + self.therapeutic_procedure_type.check() + + def select_diagnostic_procedure_type(self) -> None: + """ + This method is designed to select the diagnostic procedure type. + It selects the diagnostic procedure type. + """ + self.diagnostic_procedure_type.check() + + def click_show_completion_proof_information(self) -> None: + """ + This method is designed to click on the show completion proof information link. + It clicks on the show completion proof information link. + """ + self.click(self.show_completion_proof_information_details) + + def click_show_failure_information(self) -> None: + """ + This method is designed to click on the show failure information link. + It clicks on the show failure information link. + """ + self.click(self.show_failure_information_details) + + def click_add_polyp_button(self) -> None: + """ + This method is designed to click on the add polyp button. + It clicks on the add polyp button. + """ + self.click(self.add_polyp_button) + self.page.wait_for_timeout(1000) + + def click_polyp1_add_intervention_button(self) -> None: + """ + This method is designed to click on the add intervention button for polyp 1. + It clicks on the add intervention button for polyp 1. + """ + self.click(self.polyp1_add_intervention_button) + + def click_polyp2_add_intervention_button(self) -> None: + """ + This method is designed to click on the add intervention button for polyp 2. + It clicks on the add intervention button for polyp 2. + """ + self.click(self.polyp2_add_intervention_button) + + def check_dataset_complete_checkbox(self) -> None: + """ + This method is designed to check the dataset complete checkbox. + It checks the dataset complete checkbox. + """ + self.dataset_complete_checkbox.check() + + def click_save_dataset_button(self) -> None: + """ + This method is designed to click on the save dataset button. + It clicks on the save dataset button. + """ + self.safe_accept_dialog(self.save_dataset_button) + + def expect_text_to_be_visible(self, text: str) -> None: + """ + This method is designed to expect a text to be visible on the page. + It checks if the given text is visible. + + Args: + text (str): The text to check for visibility. + """ + expect(self.page.get_by_text(text)).to_contain_text(text) + + +class SiteLookupOptions(StrEnum): + """Enum for site lookup options""" + + RL401 = "35317" + RL402 = "42805" + RL403 = "42804" + RL404 = "42807" + RL405 = "42808" + + +class PractitionerOptions(StrEnum): + """ + Enum for practitioner options + Only the first four options are present in this class + """ + + AMID_SNORING = "1251" + ASTONISH_ETHANOL = "82" + DEEP_POLL_DERBY = "2033" + DOORFRAME_THIRSTY = "2034" + + +class TestingClinicianOptions(StrEnum): + """Enum for testing clinician options""" + + __test__ = False + + BORROWING_PROPERTY = "886" + CLAUSE_CHARTING = "918" + CLUTTER_PUMMEL = "916" + CONSONANT_TRACTOR = "101" + + +class AspirantEndoscopistOptions(StrEnum): + """Enum for aspirant endoscopist options""" + + ITALICISE_AMNESTY = "1832" + + +class DrugTypeOptions(StrEnum): + """Enum for drug type options""" + + BISACODYL = "200537~Tablet(s)" + KLEAN_PREP = "200533~Sachet(s)" + PICOLAX = "200534~Sachet(s)" + SENNA_LIQUID = "203067~5ml Bottle(s)" + SENNA = "200535~Tablet(s)" + MOVIPREP = "200536~Sachet(s)" + CITRAMAG = "200538~Sachet(s)" + MANNITOL = "200539~Litre(s)" + GASTROGRAFIN = "200540~Mls Solution" + PHOSPHATE_ENEMA = "200528~Sachet(s)" + MICROLAX_ENEMA = "200529~Sachet(s)" + OSMOSPREP = "203063~Tablet(s)" + FLEET_PHOSPHO_SODA = "203064~Mls Solution" + CITRAFLEET = "203065~Sachet(s)" + PLENVU = "305487~Sachet(s)" + OTHER = "203066" + + +class BowelPreparationQualityOptions(StrEnum): + """Enum for bowel preparation quality options""" + + EXCELLENT = "305579" + GOOD = "17016" + FAIR = "17017" + POOR = "17995~Enema down scope~305582" + INADEQUATE = "305581~~305582" + + +class ComfortOptions(StrEnum): + """Enum for comfort during examination / recovery options""" + + NO_DISCOMFORT = "18505" + MINIMAL_DISCOMFORT = "17273" + MILD_DISCOMFORT = "17274" + MODERATE_DISCOMFORT = "17275" + SEVERE_DISCOMFORT = "17276" + + +class EndoscopyLocationOptions(StrEnum): + """Enum for endoscopy location options""" + + ANUS = "17231~Scope not inserted clinical reason~204342" + RECTUM = "17232~Scope not inserted clinical reason~204342" + SIGMOID_COLON = "17233" + DESCENDING_COLON = "17234" + SPLENIC_FLEXURE = "17235" + TRANSVERSE_COLON = "17236" + HEPATIC_FLEXURE = "17237" + ASCENDING_COLON = "17238" + CAECUM = "17239~Colonoscopy Complete" + ILEUM = "17240~Colonoscopy Complete" + ANASTOMOSIS = "17241~Colonoscopy Complete" + APPENDIX = "17242~Colonoscopy Complete" + + +class YesNoOptions(StrEnum): + """Enum for scope imager used options""" + + YES = "17058" + NO = "17059" + + +class InsufflationOptions(StrEnum): + """Enum for insufflation options""" + + AIR = "200547" + CO2 = "200548" + CO2_AIR = "200549" + AIR_CO2 = "200550" + WATER = "306410" + WATER_CO2 = "305727" + WATER_AIR = "305728" + WATER_AIR_CO2 = "305729" + + +class OutcomeAtTimeOfProcedureOptions(StrEnum): + """Enum for outcome at time of procedure options""" + + LEAVE_DEPARTMENT = "17148~Complications are optional" + PLANNED_ADMISSION = "17998~Complications are optional" + UNPLANNED_ADMISSION = "17147~Complications are mandatory" + + +class LateOutcomeOptions(StrEnum): + """Enum for late outcome options""" + + NO_COMPLICATIONS = "17216~Complications are not required" + CONDITION_RESOLVED = "17217~Complications are mandatory" + TELEPHONE_CONSULTATION = "17218~Complications are mandatory" + OUTPATIENT_CONSULTATION = "17219~Complications are mandatory" + HOSPITAL_ADMISSION = "17220~Complications are mandatory" + + +class CompletionProofOptions(StrEnum): + """Enum for completion proof options""" + + PHOTO_ANASTOMOSIS = "200573" + PHOTO_APPENDIX = "200574" + PHOTO_ILEO = "200575" + PHOTO_TERMINAL_ILEUM = "200576" + VIDEO_ANASTOMOSIS = "200577" + VIDEO_APPENDIX = "200578" + VIDEO_ILEO = "200579" + VIDEO_TERMINAL_ILEUM = "200580" + NOT_POSSIBLE = "203007" + + +class FailureReasonsOptions(StrEnum): + """Enum for failure reasons options""" + + NO_FAILURE_REASONS = "18500" + ADHESION = "17165" + ADVERSE_REACTION_BOWEL = "200253~AVI" + ADVERSE_REACTION_IV = "17767~AVI" + ANAPHYLACTIC_REACTION = "17978" + BLEEDING_INCIDENT = "205148" + BLEEDING_MINOR = "205149~AVI" + BLEEDING_INTERMEDIATE = "205150~AVI" + BLEEDING_MAJOR = "205151~AVI" + BLEEDING_UNCLEAR = "205152~AVI" + CARDIAC_ARREST = "17161~AVI" + CARDIO_RESPIRATORY = "200598~AVI" + DEATH = "17176~AVI" + EQUIPMENT_FAILURE = "17173~AVI" + LOOPING = "17166" + OBSTRUCTION = "17170~AVI, Requires Other Finding" + PAIN = "17155" + PATIENT_UNWELL = "17164~AVI" + PERFORATION = "205153~AVI" + + +class PolypClassificationOptions(StrEnum): + """Enum for polyp classification options""" + + LP = "17296" + LSP = "200596" + LS = "17295" + LLA = "200595" + LLB = "200591" + LLC = "200592" + LST_G = "200593" + LST_NG = "200594" + LLA_C = "200683" + + +class PolypAccessOptions(StrEnum): + """Enum for polyp access options""" + + EASY = "305583" + DIFFICULT = "305584" + NOT_KNOWN = "17060" + + +class PolypInterventionModalityOptions(StrEnum): + """Enum for polyp intervention modality options""" + + POLYPECTOMY = "17189~Resection" + EMR = "17193~Resection" + ESD = "17520~Resection" + + +class PolypInterventionDeviceOptions(StrEnum): + """Enum for polyp intervention device options""" + + HOT_SNARE = "17070" + HOT_BIOPSY = "17071~En-bloc" + COLD_SNARE = "17072" + COLD_BIOPSY = "17073~En-bloc" + + +class PolypInterventionExcisionTechniqueOptions(StrEnum): + """Enum for polyp intervention excision technique options""" + + EN_BLOC = "17751" + PIECE_MEAL = "17750~~305578" diff --git a/pages/datasets/subject_datasets_page.py b/pages/datasets/subject_datasets_page.py new file mode 100644 index 00000000..c19cbfde --- /dev/null +++ b/pages/datasets/subject_datasets_page.py @@ -0,0 +1,45 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class SubjectDatasetsPage(BasePage): + """Subject Datasets Page locators, and methods for interacting with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Subject datasets page locators + self.colonoscopy_show_dataset_button = ( + self.page.locator("div") + # Note: The "(1 Dataset)" part of the line below may be dynamic and may change based on the actual dataset count. + .filter( + has_text="Colonoscopy Assessment (1 Dataset) Show Dataset" + ).get_by_role("link") + ) + self.investigation_show_dataset_button = ( + self.page.locator("div") + # Note: The "(1 Dataset)" part of the line below may be dynamic and may change based on the actual dataset count. + .filter(has_text="Investigation (1 Dataset) Show Dataset").get_by_role( + "link" + ) + ) + + def click_colonoscopy_show_datasets(self) -> None: + """Clicks on the 'Show Dataset' button for the Colonoscopy Assessment row on the Subject Datasets Page.""" + self.click(self.colonoscopy_show_dataset_button) + + def click_investigation_show_datasets(self) -> None: + """Clicks on the 'Show Dataset' button for the Investigation row on the Subject Datasets Page.""" + self.click(self.investigation_show_dataset_button) + + def check_investigation_dataset_complete(self) -> None: + """ + Verify that the investigation dataset is marked as complete. + + """ + expect( + self.page.locator( + "h4:has-text('Investigation') span.softHighlight", + has_text="** Completed **", + ) + ).to_be_visible() diff --git a/pages/download/__init__.py b/pages/download/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pages/download/batch_download_request_and_retrieval_page.py b/pages/download/batch_download_request_and_retrieval_page.py new file mode 100644 index 00000000..6f02b34f --- /dev/null +++ b/pages/download/batch_download_request_and_retrieval_page.py @@ -0,0 +1,24 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class BatchDownloadRequestAndRetrievalPage(BasePage): + """Batch Download Request and Retrieval Page locators, and methods for interacting with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Batch Download Request And Retrieval - page locators + self.page_form = self.page.locator('form[name="frm"]') + + def expect_form_to_have_warning(self) -> None: + """Checks if the form contains a warning message about FS Screening data not being downloaded.""" + expect(self.page_form).to_contain_text( + "Warning - FS Screening data will not be downloaded" + ) + + def verify_batch_download_request_and_retrieval_title(self) -> None: + """Verifies that the Batch Download Request and Retrieval page title is displayed correctly.""" + self.bowel_cancer_screening_page_title_contains_text( + "Batch Download Request and Retrieval" + ) diff --git a/pages/download/downloads_page.py b/pages/download/downloads_page.py new file mode 100644 index 00000000..96ad3ebf --- /dev/null +++ b/pages/download/downloads_page.py @@ -0,0 +1,39 @@ +from playwright.sync_api import Page +from pages.base_page import BasePage + + +class DownloadsPage(BasePage): + """Downloads Page locators, and methods for interacting with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Downloads Page + self.individual_download_request_page = self.page.get_by_role( + "link", name="Individual Download Request" + ) + self.list_of_individual_downloads_page = self.page.get_by_role( + "link", name="List of Individual Downloads" + ) + self.batch_download_request_and_page = self.page.get_by_role( + "link", name="Batch Download Request and" + ) + self.list_of_batch_downloads_page = self.page.get_by_role( + "cell", name="List of Batch Downloads", exact=True + ) + + def go_to_individual_download_request_page(self) -> None: + """Clicks on the 'Individual Download Request' link on the Downloads page.""" + self.click(self.individual_download_request_page) + + def go_to_list_of_individual_downloads_page(self) -> None: + """Clicks on the 'List of Individual Downloads' link on the Downloads page.""" + self.click(self.list_of_individual_downloads_page) + + def go_to_batch_download_request_and_page(self) -> None: + """Clicks on the 'Batch Download Request and Retrieval' link on the Downloads page.""" + self.click(self.batch_download_request_and_page) + + def go_to_list_of_batch_downloads_page(self) -> None: + """Clicks on the 'List of Batch Downloads' link on the Downloads page.""" + self.click(self.list_of_batch_downloads_page) diff --git a/pages/download/individual_download_request_and_retrieval_page.py b/pages/download/individual_download_request_and_retrieval_page.py new file mode 100644 index 00000000..57bb8ba9 --- /dev/null +++ b/pages/download/individual_download_request_and_retrieval_page.py @@ -0,0 +1,24 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class IndividualDownloadRequestAndRetrievalPage(BasePage): + """Individual Download Request and Retrieval Page locators, and methods for interacting with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Individual Download Request And Retrieval - page locators + self.page_form = self.page.locator('form[name="frm"]') + + def verify_individual_download_request_and_retrieval_title(self) -> None: + """Verifies that the Individual Download Request and Retrieval page title is displayed correctly.""" + self.bowel_cancer_screening_page_title_contains_text( + "Individual Download Request and Retrieval" + ) + + def expect_form_to_have_warning(self) -> None: + """Checks if the form contains a warning message about FS Screening data not being downloaded.""" + expect(self.page_form).to_contain_text( + "Warning - FS Screening data will not be downloaded" + ) diff --git a/pages/download/list_of_individual_downloads_page.py b/pages/download/list_of_individual_downloads_page.py new file mode 100644 index 00000000..7ab21ecd --- /dev/null +++ b/pages/download/list_of_individual_downloads_page.py @@ -0,0 +1,17 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class ListOfIndividualDownloadsPage(BasePage): + """List of Individual Downloads Page locators, and methods for interacting with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # List Of Individual Downloads - page locators, methods + + def verify_list_of_individual_downloads_title(self) -> None: + """Verifies that the List of Individual Downloads page title is displayed correctly.""" + self.bowel_cancer_screening_page_title_contains_text( + "List of Individual Downloads" + ) diff --git a/pages/fit_test_kits/__init__.py b/pages/fit_test_kits/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pages/fit_test_kits/fit_rollout_summary_page.py b/pages/fit_test_kits/fit_rollout_summary_page.py new file mode 100644 index 00000000..1591500f --- /dev/null +++ b/pages/fit_test_kits/fit_rollout_summary_page.py @@ -0,0 +1,16 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class FITRolloutSummaryPage(BasePage): + """FIT Rollout Summary Page locators, and methods for interacting with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # FIT Rollout Summary - page locators + self.fit_rollout_summary_body = self.page.locator("body") + + def verify_fit_rollout_summary_body(self) -> None: + """Verifies that the FIT Rollout Summary page body is displayed correctly.""" + expect(self.fit_rollout_summary_body).to_contain_text("FIT Rollout Summary") diff --git a/pages/fit_test_kits/fit_test_kits_page.py b/pages/fit_test_kits/fit_test_kits_page.py new file mode 100644 index 00000000..df48dddb --- /dev/null +++ b/pages/fit_test_kits/fit_test_kits_page.py @@ -0,0 +1,89 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class FITTestKitsPage(BasePage): + """FIT Test Kits Page locators, and methods for interacting with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Downloads Page + self.fit_rollout_summary_page = self.page.get_by_role( + "link", name="FIT Rollout Summary" + ) + self.log_devices_page = self.page.get_by_role("link", name="Log Devices") + self.view_fit_kit_result_page = self.page.get_by_role( + "link", name="View FIT Kit Result" + ) + self.kit_service_management_page = self.page.get_by_role( + "link", name="Kit Service Management" + ) + self.kit_result_audit_page = self.page.get_by_role( + "link", name="Kit Result Audit" + ) + self.view_algorithm_page = self.page.get_by_role("link", name="View Algorithm") + self.view_screening_centre_fit_page = self.page.get_by_role( + "link", name="View Screening Centre FIT" + ) + self.screening_incidents_list_page = self.page.get_by_role( + "link", name="Screening Incidents List" + ) + self.manage_qc_products_page = self.page.get_by_role( + "link", name="Manage QC Products" + ) + self.maintain_analysers_page = self.page.get_by_role( + "link", name="Maintain Analysers" + ) + self.fit_device_id = self.page.get_by_role("textbox", name="FIT Device ID") + self.sc_fit_configuration_page_screening_centre_dropdown = page.locator( + "#screeningCentres" + ) + + def verify_fit_test_kits_title(self) -> None: + """Verifies that the FIT Test Kits page title is displayed correctly.""" + self.bowel_cancer_screening_page_title_contains_text("FIT Test Kits") + + def go_to_fit_rollout_summary_page(self) -> None: + """Navigates to the FIT Rollout Summary page.""" + self.click(self.fit_rollout_summary_page) + + def go_to_log_devices_page(self) -> None: + """Navigates to the Log Devices page.""" + self.click(self.log_devices_page) + + def go_to_view_fit_kit_result(self) -> None: + """Navigates to the View FIT Kit Result page.""" + self.click(self.view_fit_kit_result_page) + + def go_to_kit_service_management(self) -> None: + """Navigates to the Kit Service Management page.""" + self.click(self.kit_service_management_page) + + def go_to_kit_result_audit(self) -> None: + """Navigates to the Kit Result Audit page.""" + self.click(self.kit_result_audit_page) + + def go_to_view_algorithm(self) -> None: + """Navigates to the View Algorithm page.""" + self.click(self.view_algorithm_page) + + def go_to_view_screening_centre_fit(self) -> None: + """Navigates to the View Screening Centre FIT page.""" + self.click(self.view_screening_centre_fit_page) + + def go_to_screening_incidents_list(self) -> None: + """Navigates to the Screening Incidents List page.""" + self.click(self.screening_incidents_list_page) + + def go_to_manage_qc_products(self) -> None: + """Navigates to the Manage QC Products page.""" + self.click(self.manage_qc_products_page) + + def go_to_maintain_analysers(self) -> None: + """Navigates to the Maintain Analysers page.""" + self.click(self.maintain_analysers_page) + + def go_to_fit_device_id(self) -> None: + """Navigates to the FIT Device ID field.""" + self.click(self.fit_device_id) diff --git a/pages/fit_test_kits/kit_result_audit_page.py b/pages/fit_test_kits/kit_result_audit_page.py new file mode 100644 index 00000000..3823e562 --- /dev/null +++ b/pages/fit_test_kits/kit_result_audit_page.py @@ -0,0 +1,17 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class KitResultAuditPage(BasePage): + """Kit Results Audit Page locators, and methods for interacting with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Kit Result Audit - page locators, methods + + def verify_kit_result_audit_title(self) -> None: + """Verifies that the Kit Result Audit page title is displayed correctly.""" + self.bowel_cancer_screening_page_title_contains_text( + "Kit Result Audit" + ) diff --git a/pages/fit_test_kits/kit_service_management_page.py b/pages/fit_test_kits/kit_service_management_page.py new file mode 100644 index 00000000..8ecf2677 --- /dev/null +++ b/pages/fit_test_kits/kit_service_management_page.py @@ -0,0 +1,15 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class KitServiceManagementPage(BasePage): + """Kit Service Management Page locators, and methods for interacting with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # KIT Service Management - page locators, methods + + def verify_kit_service_management_title(self) -> None: + """Verifies that the Kit Service Management page title is displayed correctly.""" + self.bowel_cancer_screening_page_title_contains_text("Kit Service Management") diff --git a/pages/fit_test_kits/log_devices_page.py b/pages/fit_test_kits/log_devices_page.py new file mode 100644 index 00000000..2e148e91 --- /dev/null +++ b/pages/fit_test_kits/log_devices_page.py @@ -0,0 +1,80 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage +from enum import Enum +from datetime import datetime +from utils.calendar_picker import CalendarPicker + + +class LogDevicesPage(BasePage): + """Log Devices Page locators, and methods for interacting with the page""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Log Devices - page links + self.fit_device_id_field = self.page.get_by_role( + "textbox", name="FIT Device ID" + ) + self.save_and_log_device_button = self.page.get_by_role( + "button", name="Save and Log Device" + ) + self.device_spoilt_button = self.page.get_by_role( + "button", name="Device Spoilt" + ) + self.sample_date_field = self.page.locator("#sampleDate") + self.successfully_logged_device_text = self.page.get_by_text( + "×Successfully logged device" + ) + self.spoilt_device_dropdown = self.page.get_by_label("Spoil reason drop down") + self.log_as_spoilt_button = self.page.get_by_role( + "button", name="Log as Spoilt" + ) + self.log_devices_title = self.page.locator("#page-title") + + def verify_log_devices_title(self) -> None: + """Verifies that the Log Devices page title is displayed correctly.""" + self.bowel_cancer_screening_page_title_contains_text( + "Scan Device" + ) + + def fill_fit_device_id_field(self, value) -> None: + """Fills the FIT Device ID field with the provided value and presses Enter.""" + self.fit_device_id_field.fill(value) + self.fit_device_id_field.press("Enter") + + def click_save_and_log_device_button(self) -> None: + """Clicks the 'Save and Log Device' button.""" + self.click(self.save_and_log_device_button) + + def click_device_spoilt_button(self) -> None: + """Clicks the 'Device Spoilt' button.""" + self.click(self.device_spoilt_button) + + def fill_sample_date_field(self, date: datetime) -> None: + """Fills the sample date field with the provided date.""" + CalendarPicker(self.page).calendar_picker_ddmonyy(date, self.sample_date_field) + + def verify_successfully_logged_device_text(self) -> None: + """Verifies that the 'Successfully logged device' text is displayed.""" + expect(self.successfully_logged_device_text).to_be_visible() + + def select_spoilt_device_dropdown_option(self) -> None: + """Selects the 'Broken in transit' option from the spoilt device dropdown.""" + self.spoilt_device_dropdown.select_option( + SpoiltDeviceOptions.BROKEN_IN_TRANSIT.value + ) + + def click_log_as_spoilt_button(self) -> None: + """Clicks the 'Log as Spoilt' button.""" + self.click(self.log_as_spoilt_button) + + +class SpoiltDeviceOptions(Enum): + """Enumeration for the options available in the spoilt device dropdown.""" + + BROKEN_IN_TRANSIT = "205156" + DEVICE_MISUSE = "205157" + NOT_USED = "205159" + SAMPLE_BUFFER_LOST = "205158" + TOO_MUCH_SAMPLE = "306397" + OTHER = "205161" diff --git a/pages/fit_test_kits/maintain_analysers_page.py b/pages/fit_test_kits/maintain_analysers_page.py new file mode 100644 index 00000000..7c34ac97 --- /dev/null +++ b/pages/fit_test_kits/maintain_analysers_page.py @@ -0,0 +1,15 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class MaintainAnalysersPage(BasePage): + """Maintain Analysers page locators and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Maintain Analysers - page locators, methods + + def verify_maintain_analysers_title(self) -> None: + """Verify the Maintain Analysers page title is displayed correctly.""" + self.bowel_cancer_screening_page_title_contains_text("Maintain Analysers") diff --git a/pages/fit_test_kits/manage_qc_products_page.py b/pages/fit_test_kits/manage_qc_products_page.py new file mode 100644 index 00000000..d3c22c07 --- /dev/null +++ b/pages/fit_test_kits/manage_qc_products_page.py @@ -0,0 +1,17 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class ManageQCProductsPage(BasePage): + """Manage QC Products page locators and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Manage QC Products - page locators, methods + + def verify_manage_qc_products_title(self) -> None: + """Verify the Manage QC Products page title is displayed correctly.""" + self.bowel_cancer_screening_page_title_contains_text( + "FIT QC Products" + ) diff --git a/pages/fit_test_kits/screening_incidents_list_page.py b/pages/fit_test_kits/screening_incidents_list_page.py new file mode 100644 index 00000000..0e7f1769 --- /dev/null +++ b/pages/fit_test_kits/screening_incidents_list_page.py @@ -0,0 +1,17 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class ScreeningIncidentsListPage(BasePage): + """Screening Incidents List page locators and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Screening Incidents List - page locators, methods + + def verify_screening_incidents_list_title(self) -> None: + """Verify the Screening Incidents List page title is displayed correctly.""" + self.bowel_cancer_screening_page_title_contains_text( + "Screening Incidents List" + ) diff --git a/pages/fit_test_kits/view_algorithms_page.py b/pages/fit_test_kits/view_algorithms_page.py new file mode 100644 index 00000000..0444e8fc --- /dev/null +++ b/pages/fit_test_kits/view_algorithms_page.py @@ -0,0 +1,16 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class ViewAlgorithmsPage(BasePage): + """View Algorithms page locators and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # View Algorithms - page locators + self.view_algorithms_body = self.page.locator("body") + + def verify_view_algorithms_body(self) -> None: + """Verify the View Algorithms page body contains text "Select Algorithm".""" + expect(self.view_algorithms_body).to_contain_text("Select Algorithm") diff --git a/pages/fit_test_kits/view_fit_kit_result_page.py b/pages/fit_test_kits/view_fit_kit_result_page.py new file mode 100644 index 00000000..08ba6f06 --- /dev/null +++ b/pages/fit_test_kits/view_fit_kit_result_page.py @@ -0,0 +1,16 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class ViewFITKitResultPage(BasePage): + """View FIT Kit Result page locators and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # View FIT Kit Result - page locators + self.view_fit_kit_result_body = self.page.locator("body") + + def verify_view_fit_kit_result_body(self) -> None: + """Verify the View FIT Kit Result page body contains text "View FIT Kit Result".""" + expect(self.view_fit_kit_result_body).to_contain_text("View FIT Kit Result") diff --git a/pages/fit_test_kits/view_screening_centre_fit_configuration_page.py b/pages/fit_test_kits/view_screening_centre_fit_configuration_page.py new file mode 100644 index 00000000..b3e7aa24 --- /dev/null +++ b/pages/fit_test_kits/view_screening_centre_fit_configuration_page.py @@ -0,0 +1,25 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class ViewScreeningCentreFITConfigurationPage(BasePage): + """View Screening Centre FIT Configuration page locators, and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # View Screening Centre FIT Configuration - page locators + self.view_screening_centre_body = self.page.locator("body") + self.screening_centre_fit_title = self.page.get_by_role( + "heading", name="View Screening Centre FIT" + ) + + def verify_view_screening_centre_body(self) -> None: + """Verify the View Screening Centre FIT Configuration page body contains text "Maintain Analysers".""" + expect(self.view_screening_centre_body).to_contain_text("Maintain Analysers") + + def verify_view_screening_centre_fit_title(self) -> None: + """Verify the View Screening Centre FIT Configuration page title contains text "View Screening Centre FIT Configuration".""" + expect(self.screening_centre_fit_title).to_contain_text( + "View Screening Centre FIT Configuration" + ) diff --git a/pages/gfobt_test_kits/__init__.py b/pages/gfobt_test_kits/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pages/gfobt_test_kits/gfobt_create_qc_kit_page.py b/pages/gfobt_test_kits/gfobt_create_qc_kit_page.py new file mode 100644 index 00000000..731952c8 --- /dev/null +++ b/pages/gfobt_test_kits/gfobt_create_qc_kit_page.py @@ -0,0 +1,70 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage +from enum import Enum + + +class CreateQCKitPage(BasePage): + """Create QC Kit page locators and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + + self.create_qc_kit_title = self.page.locator("#ntshPageTitle") + # Reading dropdown locators + self.reading1dropdown = self.page.locator("#A_C_Reading_999_0_0") + self.reading2dropdown = self.page.locator("#A_C_Reading_999_0_1") + self.reading3dropdown = self.page.locator("#A_C_Reading_999_1_0") + self.reading4dropdown = self.page.locator("#A_C_Reading_999_1_1") + self.reading5dropdown = self.page.locator("#A_C_Reading_999_2_0") + self.reading6dropdown = self.page.locator("#A_C_Reading_999_2_1") + self.save_kit = self.page.get_by_role("button", name="Save Kit") + self.kit_has_saved = self.page.locator("th") + + def verify_create_qc_kit_title(self) -> None: + """Verify the Create QC Kit page title contains text "Create QC Kit".""" + self.bowel_cancer_screening_page_title_contains_text( + "Create QC Kit" + ) + + def go_to_reading1dropdown(self, option: str) -> None: + """Selects a given option from the reading 1 dropdown.""" + self.reading1dropdown.select_option(option) + + def go_to_reading2dropdown(self, option: str) -> None: + """Selects a given option from the reading 2 dropdown.""" + self.reading2dropdown.select_option(option) + + def go_to_reading3dropdown(self, option: str) -> None: + """Selects a given option from the reading 3 dropdown.""" + self.reading3dropdown.select_option(option) + + def go_to_reading4dropdown(self, option: str) -> None: + """Selects a given option from the reading 4 dropdown.""" + self.reading4dropdown.select_option(option) + + def go_to_reading5dropdown(self, option: str) -> None: + """Selects a given option from the reading 5 dropdown.""" + self.reading5dropdown.select_option(option) + + def go_to_reading6dropdown(self, option: str) -> None: + """Selects a given option from the reading 6 dropdown.""" + self.reading6dropdown.select_option(option) + + def go_to_save_kit(self) -> None: + """Clicks the Save Kit button.""" + self.click(self.save_kit) + + def verify_kit_has_saved(self) -> None: + """Verify the kit has saved by checking the page contains the text "A quality control kit has been created with the following values:".""" + expect(self.kit_has_saved).to_contain_text( + "A quality control kit has been created with the following values:" + ) + + +class ReadingDropdownOptions(Enum): + """Enum for the 'Create QC Kit Page' reading dropdown options.""" + + NEGATIVE = "NEGATIVE" + POSITIVE = "POSITIVE" + UNUSED = "UNUSED" diff --git a/pages/gfobt_test_kits/gfobt_test_kit_logging_page.py b/pages/gfobt_test_kits/gfobt_test_kit_logging_page.py new file mode 100644 index 00000000..b35a3cf3 --- /dev/null +++ b/pages/gfobt_test_kits/gfobt_test_kit_logging_page.py @@ -0,0 +1,14 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class GFOBTTestKitLoggingPage(BasePage): + """GFOBT Test Kit Logging Page locators, and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + + def verify_test_kit_logging_title(self) -> None: + """Verify the title of the GFOBT Test Kit Logging page.""" + self.bowel_cancer_screening_page_title_contains_text("Test Kit Logging") diff --git a/pages/gfobt_test_kits/gfobt_test_kit_quality_control_reading_page.py b/pages/gfobt_test_kits/gfobt_test_kit_quality_control_reading_page.py new file mode 100644 index 00000000..74c02ba0 --- /dev/null +++ b/pages/gfobt_test_kits/gfobt_test_kit_quality_control_reading_page.py @@ -0,0 +1,16 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class GFOBTTestKitQualityControlReadingPage(BasePage): + """GFOBT Test Kit Quality Control Reading Page locators, and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + + def verify_test_kit_logging_tile(self) -> None: + """Verify the title of the GFOBT Test Kit Quality Control Reading page.""" + self.bowel_cancer_screening_page_title_contains_text( + "Test Kit Quality Control Reading" + ) diff --git a/pages/gfobt_test_kits/gfobt_test_kits_page.py b/pages/gfobt_test_kits/gfobt_test_kits_page.py new file mode 100644 index 00000000..fde52fb2 --- /dev/null +++ b/pages/gfobt_test_kits/gfobt_test_kits_page.py @@ -0,0 +1,37 @@ +from playwright.sync_api import Page +from pages.base_page import BasePage + + +class GFOBTTestKitsPage(BasePage): + """GFOBT Test Kits Page locators, and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + + self.test_kit_logging_page = self.page.get_by_role( + "link", name="Test Kit Logging" + ) + self.test_kit_reading_page = self.page.get_by_role( + "link", name="Test Kit Reading" + ) + self.test_kit_result_page = self.page.get_by_role( + "link", name="View Test Kit Result" + ) + self.create_qc_kit_page = self.page.get_by_role("link", name="Create QC Kit") + + def go_to_test_kit_logging_page(self) -> None: + """Navigate to the Test Kit Logging page.""" + self.click(self.test_kit_logging_page) + + def go_to_test_kit_reading_page(self) -> None: + """Navigate to the Test Kit Reading page.""" + self.click(self.test_kit_reading_page) + + def go_to_test_kit_result_page(self) -> None: + """Navigate to the View Test Kit Result page.""" + self.click(self.test_kit_result_page) + + def go_to_create_qc_kit_page(self) -> None: + """Navigate to the Create QC Kit page.""" + self.click(self.create_qc_kit_page) diff --git a/pages/gfobt_test_kits/gfobt_view_test_kit_result_page.py b/pages/gfobt_test_kits/gfobt_view_test_kit_result_page.py new file mode 100644 index 00000000..546da08f --- /dev/null +++ b/pages/gfobt_test_kits/gfobt_view_test_kit_result_page.py @@ -0,0 +1,14 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class ViewTestKitResultPage(BasePage): + """View Test Kit Result Page locators, and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + + def verify_view_test_kit_result_title(self) -> None: + """Verify the title of the View Test Kit Result page.""" + self.bowel_cancer_screening_page_title_contains_text("View Test Kit Result") diff --git a/pages/login/__init__.py b/pages/login/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pages/login/cognito_login_page.py b/pages/login/cognito_login_page.py new file mode 100644 index 00000000..e253adef --- /dev/null +++ b/pages/login/cognito_login_page.py @@ -0,0 +1,26 @@ +from playwright.sync_api import Page +from pages.base_page import BasePage + + +class CognitoLoginPage(BasePage): + """Cognito Login Page locators, and methods for logging in to bcss via the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + self.username = self.page.get_by_role("textbox", name="Username") + self.password = self.page.get_by_role("textbox", name="Password") + self.submit_button = self.page.get_by_role("button", name="submit") + + def login_as_user(self, username: str, password: str) -> None: + """Logs in to bcss with specified user credentials + Args: + username (str) enter a username that exists in users.json + password (str) the password for the user provided + """ + # Retrieve and enter username from users.json + self.username.fill(username) + # Retrieve and enter password from .env file + self.password.fill(password) + # Click Submit + self.click(self.submit_button) diff --git a/pages/login/login_failure_screen_page.py b/pages/login/login_failure_screen_page.py new file mode 100644 index 00000000..987c1f26 --- /dev/null +++ b/pages/login/login_failure_screen_page.py @@ -0,0 +1,18 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class LoginFailureScreenPage(BasePage): + """Login Failure Screen Page locators, and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Login failure message + self.login_failure_msg = self.page.get_by_role( + "heading", name="Sorry, BCSS is unavailable" + ) + + def verify_login_failure_screen_is_displayed(self) -> None: + """Verifies that the login failure screen is displayed.""" + expect(self.login_failure_msg).to_be_visible() diff --git a/pages/login/login_page.py b/pages/login/login_page.py new file mode 100644 index 00000000..73881e9a --- /dev/null +++ b/pages/login/login_page.py @@ -0,0 +1,34 @@ +import os +from playwright.sync_api import Page +from utils.user_tools import UserTools +from pages.base_page import BasePage +from dotenv import load_dotenv + + +class BcssLoginPage(BasePage): + """Login Page locators, and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + self.page.goto("/") + self.username = self.page.get_by_role("textbox", name="Username") + self.password = self.page.get_by_role("textbox", name="Password") + self.submit_button = self.page.get_by_role("button", name="submit") + load_dotenv() # Take environment variables from .env + + def login_as_user(self, username: str) -> None: + """Logs in to bcss with specified user credentials + Args: + username (str) enter a username that exists in users.json + """ + # Retrieve and enter username from users.json + user_details = UserTools.retrieve_user(username) + self.username.fill(user_details["username"]) + # Retrieve and enter password from .env file + password = os.getenv("BCSS_PASS") + if password is None: + raise ValueError("Environment variable 'BCSS_PASS' is not set") + self.password.fill(password) + # Click Submit + self.click(self.submit_button) diff --git a/pages/logout/__init__.py b/pages/logout/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pages/logout/log_out_page.py b/pages/logout/log_out_page.py new file mode 100644 index 00000000..eee4a34e --- /dev/null +++ b/pages/logout/log_out_page.py @@ -0,0 +1,25 @@ +from playwright.sync_api import Page, expect +import logging +from pages.base_page import BasePage + + +class LogoutPage(BasePage): + """Logout locators, and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Logout page locators + self.log_out_msg = self.page.get_by_role("heading", name="You have logged out") + + def verify_log_out_page(self) -> None: + """Verifies that the logout message is displayed.""" + expect(self.log_out_msg).to_be_visible() + + def log_out(self, close_page: bool = True) -> None: + """Logs out of the application and verifies the logout message is displayed.""" + logging.info("Logging Out") + self.click_log_out_link() + expect(self.log_out_msg).to_be_visible() + if close_page: + self.page.close() diff --git a/pages/lynch_surveillance/__init__.py b/pages/lynch_surveillance/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pages/lynch_surveillance/lynch_invitation_page.py b/pages/lynch_surveillance/lynch_invitation_page.py new file mode 100644 index 00000000..79bd3104 --- /dev/null +++ b/pages/lynch_surveillance/lynch_invitation_page.py @@ -0,0 +1,18 @@ +from playwright.sync_api import Page +from pages.base_page import BasePage + + +class LynchInvitationPage(BasePage): + """Lynch Invitation Page locators, and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Lynch Invitation Page - Links + self.set_lynch_invitation_rates_link = self.page.get_by_role( + "link", name="Set Lynch Invitation Rates" + ) + + def click_set_lynch_invitation_rates_link(self) -> None: + """Clicks the 'Set Lynch Invitation Rates' link.""" + self.click(self.set_lynch_invitation_rates_link) diff --git a/pages/lynch_surveillance/set_lynch_invitation_rates_page.py b/pages/lynch_surveillance/set_lynch_invitation_rates_page.py new file mode 100644 index 00000000..c4b97a7b --- /dev/null +++ b/pages/lynch_surveillance/set_lynch_invitation_rates_page.py @@ -0,0 +1,17 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class SetLynchInvitationRatesPage(BasePage): + """Set Lynch Invitation Rates Page locators, and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Lynch Invitation Page - Links, methods + + def verify_set_lynch_invitation_rates_title(self) -> None: + """Verifies that the Set Lynch Invitation Rates title is displayed.""" + self.bowel_cancer_screening_page_title_contains_text( + "Set Lynch Surveillance Invitation Rates" + ) diff --git a/pages/organisations/__init__.py b/pages/organisations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pages/organisations/organisations_page.py b/pages/organisations/organisations_page.py new file mode 100644 index 00000000..ccdfa687 --- /dev/null +++ b/pages/organisations/organisations_page.py @@ -0,0 +1,125 @@ +from playwright.sync_api import Page +from pages.base_page import BasePage +from typing import List + +class OrganisationsPage(BasePage): + """Organisations Page locators, and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + + # Organisations page links + self.screening_centre_parameters_page = self.page.get_by_role( + "link", name="Screening Centre Parameters" + ) + self.organisation_parameters_page = self.page.get_by_role( + "link", name="Organisation Parameters" + ) + self.organisations_and_site_details_page = self.page.get_by_role( + "link", name="Organisation and Site Details" + ) + self.gp_practice_endorsement_page = self.page.get_by_role( + "link", name="GP Practice Endorsement" + ) + self.upload_nacs_data_bureau_page = self.page.get_by_role( + "link", name="Upload NACS data (Bureau)" + ) + self.bureau_page = self.page.get_by_role("link", name="Bureau") + + def go_to_screening_centre_parameters_page(self) -> None: + """Clicks the 'Screening Centre Parameters' link.""" + self.click(self.screening_centre_parameters_page) + + def go_to_organisation_parameters_page(self) -> None: + """Clicks the 'Organisation Parameters' link.""" + self.click(self.organisation_parameters_page) + + def go_to_organisations_and_site_details_page(self) -> None: + """Clicks the 'Organisation and Site Details' link.""" + self.click(self.organisations_and_site_details_page) + + def go_to_gp_practice_endorsement_page(self) -> None: + """Clicks the 'GP Practice Endorsement' link.""" + self.click(self.gp_practice_endorsement_page) + + def go_to_upload_nacs_data_bureau_page(self) -> None: + """Clicks the 'Upload NACS data (Bureau)' link.""" + self.click(self.upload_nacs_data_bureau_page) + + def go_to_bureau_page(self) -> None: + """Clicks the 'Bureau' link.""" + self.click(self.bureau_page) + +class OrganisationSwitchPage: + """Page Object Model for interacting with the Organisation Switch page.""" + + def __init__(self, page: Page): + """ + Initializes the OrganisationSwitchPage with locators for key elements. + + Args: + page (Page): The Playwright Page object representing the browser page. + """ + self.page = page + self.radio_buttons = self.page.locator("input[type='radio']") + self.selected_radio = self.page.locator("input[name='organisation']:checked") + self.continue_button = self.page.get_by_role("button", name="Continue") + self.select_org_link = self.page.get_by_role("link", name="Select Org") + self.login_info = self.page.locator("td.loginInfo") + + def click(self, locator) -> None: + """ + Clicks the given locator element. + + Args: + locator: A Playwright Locator object to be clicked. + """ + locator.click() + + def get_available_organisation_ids(self) -> List[str]: + """ + Retrieves the list of available organisation IDs from the radio button on the page. + + Returns: + List[str]: A list of organisation ID strings. + """ + org_ids = [] + count = self.radio_buttons.count() + for element in range(count): + org_id = self.radio_buttons.nth(element).get_attribute("id") + if org_id: + org_ids.append(org_id) + return org_ids + + def select_organisation_by_id(self, org_id: str) -> None: + """ + Selects an organisation radio button by its ID. + + Args: + org_id (str): The ID of the organisation to select. + """ + self.click(self.page.locator(f"#{org_id}")) + + def click_continue(self) -> None: + """ + Clicks the 'Continue' button on the page. + """ + self.click(self.continue_button) + + def click_select_org_link(self) -> None: + """ + Clicks the 'Select Org' link to return to the organisation selection page. + """ + self.click(self.select_org_link) + + def get_logged_in_text(self) -> str: + """ + Retrieves the logged-in user information from the login info section. + + Returns: + str: The text indicating the logged-in user's role or name. + """ + return self.login_info.inner_text() + + diff --git a/pages/reports/__init__.py b/pages/reports/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pages/reports/reports_page.py b/pages/reports/reports_page.py new file mode 100644 index 00000000..a4eb4ba9 --- /dev/null +++ b/pages/reports/reports_page.py @@ -0,0 +1,228 @@ +from pages.base_page import BasePage +from utils.table_util import TableUtils + + +class ReportsPage(BasePage): + """Reports Page locators, and methods for interacting with the page.""" + + def __init__(self, page): + super().__init__(page) + self.page = page + + # Initialize TableUtils for different tables + self.failsafe_reports_sub_links_table = TableUtils(page, "#listReportDataTable") + self.fail_safe_reports_screening_subjects_with_inactive_open_episodes_table = ( + TableUtils(page, "#subjInactiveOpenEpisodes") + ) + + # Reports page main menu links + self.bureau_reports_link = self.page.get_by_text("Bureau Reports") + self.failsafe_reports_link = self.page.get_by_role( + "link", name="Failsafe Reports" + ) + self.operational_reports_link = self.page.get_by_role( + "link", name="Operational Reports" + ) + self.strategic_reports_link = self.page.get_by_role( + "link", name="Strategic Reports" + ) + self.cancer_waiting_times_reports_link = self.page.get_by_role( + "link", name="Cancer Waiting Times Reports" + ) + self.dashboard_link = self.page.get_by_role("link", name="Dashboard") + self.qa_report_dataset_completion_link = self.page.get_by_role( + "link", name="QA Report : Dataset Completion" + ) + + # Reports pages shared buttons, locators & links + self.refresh_page_button = self.page.get_by_role("button", name="Refresh") + self.reports_update_button = self.page.get_by_role("button", name="Update") + self.report_start_date_field = self.page.get_by_role( + "textbox", name="Report Start Date" + ) + self.qa_report_dataset_completion_link = self.page.get_by_text( + "QA Report : Dataset Completion" + ) + + # Generate Report button locators + self.generate_report_button = self.page.get_by_role( + "button", name="Generate Report" + ) + self.operational_reports_sp_appointments_generate_report_button = ( + self.page.locator("#submitThisForm") + ) + + # Set patients screening centre dropdown locators + SCREENING_CENTRE = "Screening Centre" + self.set_patients_screening_centre_dropdown = self.page.locator( + "#cboScreeningCentre" + ) + self.six_weeks_availability_not_set_up_set_patients_screening_centre_dropdown = self.page.get_by_label( + SCREENING_CENTRE + ) + self.practitioner_appointments_set_patients_screening_centre_dropdown = ( + page.get_by_label(SCREENING_CENTRE) + ) + self.attendance_not_updated_set_patients_screening_centre_dropdown = ( + page.get_by_label(SCREENING_CENTRE) + ) + + # Select screening practitioner dropdown locators + self.screening_practitioner_dropdown = self.page.locator("#A_C_NURSE") + + # Report Timestamp locators + self.common_report_timestamp_element = self.page.locator("b") + self.subject_ceased_report_timestamp_element = self.page.locator( + "#displayGenerateDate > tbody > tr > td > b" + ) + self.fobt_logged_not_read_report_timestamp_element = self.page.locator( + "#report-generated" + ) + self.six_weeks_availability_not_set_up_report_timestamp_element = ( + self.page.locator("#displayGenerateDate") + ) + + # Failsafe Reports menu links + self.date_report_last_requested_link = self.page.get_by_role( + "link", name="Date Report Last Requested" + ) + self.screening_subjects_with_inactive_open_episode_link = self.page.get_by_role( + "link", name="Screening Subjects With" + ) + self.subjects_ceased_due_to_date_of_birth_changes_link = self.page.get_by_role( + "link", name="Subjects Ceased Due to Date" + ) + self.allocate_sc_for_patient_movements_within_hub_boundaries_link = ( + self.page.get_by_role( + "link", name="Allocate SC for Patient Movements within Hub Boundaries" + ) + ) + self.allocate_sc_for_patient_movements_into_your_hub_link = ( + self.page.get_by_role( + "link", name="Allocate SC for Patient Movements into your Hub" + ) + ) + self.identify_and_link_new_gp_link = self.page.get_by_role( + "link", name="Identify and link new GP" + ) + + # Operational Reports menu links + self.appointment_attendance_not_updated_link = self.page.get_by_role( + "link", name="Appointment Attendance Not" + ) + self.fobt_kits_logged_but_not_read_link = self.page.get_by_role( + "link", name="FOBT Kits Logged but Not Read" + ) + self.demographic_update_inconsistent_with_manual_update_link = ( + self.page.get_by_role("link", name="Demographic Update") + ) + self.screening_practitioner_6_weeks_availability_not_set_up_report_link = ( + page.get_by_role("link", name="Screening Practitioner 6") + ) + self.screening_practitioner_appointments_link = self.page.get_by_role( + "link", name="Screening Practitioner Appointments" + ) + + # Reports page main menu navigation + def go_to_failsafe_reports_page(self) -> None: + """Clicks the 'Failsafe Reports' link.""" + self.click(self.failsafe_reports_link) + + def go_to_operational_reports_page(self) -> None: + """Clicks the 'Operational Reports' link.""" + self.click(self.operational_reports_link) + + def go_to_strategic_reports_page(self) -> None: + """Clicks the 'Strategic Reports' link.""" + self.click(self.strategic_reports_link) + + def go_to_cancer_waiting_times_reports_page(self) -> None: + """Clicks the 'Cancer Waiting Times Reports' link.""" + self.click(self.cancer_waiting_times_reports_link) + + def go_to_dashboard(self) -> None: + """Clicks the 'Dashboard' link.""" + self.click(self.dashboard_link) + + # Reports pages shared buttons and actions + def click_refresh_button(self) -> None: + """Clicks the 'Refresh' button on the reports pages.""" + self.click(self.refresh_page_button) + + def click_generate_report_button(self) -> None: + """Clicks the 'Generate Report' button on the reports pages.""" + self.click(self.generate_report_button) + + def click_reports_pages_update_button(self) -> None: + """Clicks the 'Update' button on the reports pages.""" + self.click(self.reports_update_button) + + # Failsafe Reports menu links + def go_to_date_report_last_requested_page(self) -> None: + """Clicks the 'Date Report Last Requested' link on the Failsafe Reports Menu.""" + self.click(self.date_report_last_requested_link) + + def go_to_screening_subjects_with_inactive_open_episode_page(self) -> None: + """Clicks the 'Screening Subjects With Inactive Open Episode' link on the Failsafe Reports Menu.""" + self.click(self.screening_subjects_with_inactive_open_episode_link) + + def go_to_subjects_ceased_due_to_date_of_birth_changes_page(self) -> None: + """Clicks the 'Subjects Ceased Due to Date of Birth Changes' link on the Failsafe Reports Menu.""" + self.click(self.subjects_ceased_due_to_date_of_birth_changes_link) + + def go_to_allocate_sc_for_patient_movements_within_hub_boundaries_page( + self, + ) -> None: + """Clicks the 'Allocate SC for Patient Movements within Hub Boundaries' link on the Failsafe Reports Menu.""" + self.click(self.allocate_sc_for_patient_movements_within_hub_boundaries_link) + + def go_to_allocate_sc_for_patient_movements_into_your_hub_page(self) -> None: + """Clicks the 'Allocate SC for Patient Movements into your Hub' link on the Failsafe Reports Menu.""" + self.click(self.allocate_sc_for_patient_movements_into_your_hub_link) + + def go_to_identify_and_link_new_gp_page(self) -> None: + """Clicks the 'Identify and link new GP' link on the Failsafe Reports Menu.""" + self.click(self.identify_and_link_new_gp_link) + + # Operational Reports menu links + def go_to_appointment_attendance_not_updated_page(self) -> None: + """Clicks the 'Appointment Attendance Not Updated' link on the Operational Reports Menu.""" + self.click(self.appointment_attendance_not_updated_link) + + def go_to_fobt_kits_logged_but_not_read_page(self) -> None: + """Clicks the 'FOBT Kits Logged but Not Read' link on the Operational Reports Menu.""" + self.click(self.fobt_kits_logged_but_not_read_link) + + def go_to_demographic_update_inconsistent_with_manual_update_page(self) -> None: + """Clicks the 'Demographic Update Inconsistent with Manual Update' link on the Operational Reports Menu.""" + self.click(self.demographic_update_inconsistent_with_manual_update_link) + + def go_to_screening_practitioner_6_weeks_availability_not_set_up_report_page( + self, + ) -> None: + """Clicks the 'Screening Practitioner 6 Weeks Availability Not Set Up Report' link on the Operational Reports Menu.""" + self.click( + self.screening_practitioner_6_weeks_availability_not_set_up_report_link + ) + + def go_to_screening_practitioner_appointments_page(self) -> None: + """Clicks the 'Screening Practitioner Appointments' link on the Operational Reports Menu.""" + self.click(self.screening_practitioner_appointments_page) + + def click_failsafe_reports_sub_links(self): + """Clicks the first NHS number link from the primary report table.""" + self.failsafe_reports_sub_links_table.click_first_link_in_column("NHS Number") + + def click_fail_safe_reports_screening_subjects_with_inactive_open_episodes_link( + self, + ): + """Clicks the first NHS number link from the primary report table.""" + self.fail_safe_reports_screening_subjects_with_inactive_open_episodes_table.click_first_link_in_column( + "NHS Number" + ) + + def click_fail_safe_reports_identify_and_link_new_gp_practices_link(self): + """Clicks the first Practice Code link from the primary report table.""" + self.failsafe_reports_sub_links_table.click_first_link_in_column( + "Practice Code" + ) diff --git a/pages/screening_practitioner_appointments/__init__.py b/pages/screening_practitioner_appointments/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pages/screening_practitioner_appointments/appointment_calendar_page.py b/pages/screening_practitioner_appointments/appointment_calendar_page.py new file mode 100644 index 00000000..2c097536 --- /dev/null +++ b/pages/screening_practitioner_appointments/appointment_calendar_page.py @@ -0,0 +1,33 @@ +from playwright.sync_api import Page +from pages.base_page import BasePage + + +class AppointmentCalendarPage(BasePage): + """Appointment Calendar Page locators, and methods for interacting with the Appointment Calendar.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Appointment Calendar - page filters + self.appointment_type_drowdown = self.page.locator("#UI_APPOINTMENT_TYPE") + self.screening_centre_dropdown = self.page.locator("#UI_SCREENING_CENTRE") + self.site_dropdown = self.page.locator("#UI_SITE") + self.view_appointments_on_this_day_button = self.page.get_by_role( + "button", name="View appointments on this day" + ) + + def select_appointment_type_dropdown(self, type: str) -> None: + """Selects the appointment type from the dropdown.""" + self.appointment_type_drowdown.select_option(label=type) + + def select_screening_centre_dropdown(self, screening_centre: str) -> None: + """Selects the screening centre from the dropdown.""" + self.screening_centre_dropdown.select_option(label=screening_centre) + + def select_site_dropdown(self, site: str) -> None: + """Selects the site from the dropdown.""" + self.site_dropdown.select_option(label=site) + + def click_view_appointments_on_this_day_button(self) -> None: + """Clicks the 'View appointments on this day' button.""" + self.click(self.view_appointments_on_this_day_button) diff --git a/pages/screening_practitioner_appointments/appointment_detail_page.py b/pages/screening_practitioner_appointments/appointment_detail_page.py new file mode 100644 index 00000000..e4776b68 --- /dev/null +++ b/pages/screening_practitioner_appointments/appointment_detail_page.py @@ -0,0 +1,35 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class AppointmentDetailPage(BasePage): + """Appointment Detail Page locators, and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Appointment Detail - page filters + self.attendance_radio = self.page.get_by_role("radio", name="Attendance") + self.attended_check_box = self.page.locator("#UI_ATTENDED") + self.calendar_button = self.page.get_by_role("button", name="Calendar") + self.save_button = self.page.get_by_role("button", name="Save") + + def check_attendance_radio(self) -> None: + """Checks the attendance radio button.""" + self.attendance_radio.check() + + def check_attended_check_box(self) -> None: + """Checks the attended check box.""" + self.attended_check_box.check() + + def click_calendar_button(self) -> None: + """Clicks the calendar button.""" + self.click(self.calendar_button) + + def click_save_button(self) -> None: + """Clicks the save button.""" + self.click(self.save_button) + + def verify_text_visible(self, text: str) -> None: + """Verifies that the specified text is visible on the page.""" + expect(self.page.get_by_text(text)).to_be_visible() diff --git a/pages/screening_practitioner_appointments/book_appointment_page.py b/pages/screening_practitioner_appointments/book_appointment_page.py new file mode 100644 index 00000000..bdf1b58c --- /dev/null +++ b/pages/screening_practitioner_appointments/book_appointment_page.py @@ -0,0 +1,52 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage +from utils.table_util import TableUtils + + +class BookAppointmentPage(BasePage): + """Book Appointment Page locators, and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Book Appointment - page locators + self.screening_center_dropdown = self.page.locator("#UI_NEW_SCREENING_CENTRE") + self.site_dropdown = self.page.locator("#UI_NEW_SITE") + self.appointment_time_radio_button = self.page.get_by_role( + "radio", name="UI_NEW_SLOT_SELECTION_ID" + ) + self.save_button = self.page.get_by_role("button", name="Save") + self.appointments_table = TableUtils(self.page, "#displayRS") + self.current_month_displayed = self.page.locator("#MONTH_AND_YEAR") + + self.appointment_cell_locators = self.page.locator("input.twoColumnCalendar") + self.available_background_colour = "rgb(102, 255, 153)" + self.some_available_background_colour = "rgb(255, 220, 144)" + + def select_screening_centre_dropdown_option(self, screening_centre: str) -> None: + """Selects the screening centre from the dropdown.""" + self.screening_center_dropdown.select_option(label=screening_centre) + + def select_site_dropdown_option(self, screening_site: str | list) -> None: + """Selects the screening site from the dropdown and presses Enter.""" + self.site_dropdown.select_option(label=screening_site) + self.site_dropdown.press("Enter") + + def choose_appointment_time(self) -> None: + """Checks the appointment time radio button.""" + self.appointment_time_radio_button.check() + + def click_save_button(self) -> None: + """Clicks the save button.""" + self.click(self.save_button) + + def appointment_booked_confirmation_is_displayed(self, message: str) -> None: + """Checks if the appointment booked confirmation message is displayed.""" + expect(self.page.get_by_text(message)).to_be_visible() + + def get_current_month_displayed(self) -> str: + """Returns the current month displayed in the calendar.""" + current_month_displayed_content = self.current_month_displayed.text_content() + if current_month_displayed_content is None: + raise ValueError("Current month displayed is 'None'") + return current_month_displayed_content diff --git a/pages/screening_practitioner_appointments/colonoscopy_assessment_appointments_page.py b/pages/screening_practitioner_appointments/colonoscopy_assessment_appointments_page.py new file mode 100644 index 00000000..2594f52f --- /dev/null +++ b/pages/screening_practitioner_appointments/colonoscopy_assessment_appointments_page.py @@ -0,0 +1,36 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class ColonoscopyAssessmentAppointmentsPage(BasePage): + """Colonoscopy Assessment Appointments Page locators, and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Colonoscopy Assessment Appointments - page locators + self.page_header_with_title = self.page.locator( + "#page-title", + has_text="Patients that Require Colonoscopy Assessment Appointments", + ) + self.nhs_number_filter_text_field = self.page.locator("#nhsNumberFilter") + + def verify_page_header(self) -> None: + """Verifies the Colonoscopy Assessment Appointments page header is displayed correctly.""" + self.bowel_cancer_screening_page_title_contains_text( + "Patients that Require Colonoscopy Assessment Appointments" + ) + + def wait_for_page_header(self) -> None: + """Waits for the Colonoscopy Assessment Appointments page header to be displayed.""" + self.page_header_with_title.wait_for() + + def filter_by_nhs_number(self, nhs_number: str) -> None: + """Filters the Colonoscopy Assessment Appointments page by NHS number.""" + self.click(self.nhs_number_filter_text_field) + self.nhs_number_filter_text_field.fill(nhs_number) + self.nhs_number_filter_text_field.press("Enter") + + def click_nhs_number_link(self, nhs_number: str) -> None: + """Clicks the NHS number link on the Colonoscopy Assessment Appointments page.""" + self.click(self.page.get_by_role("link", name=nhs_number)) diff --git a/pages/screening_practitioner_appointments/practitioner_availability_page.py b/pages/screening_practitioner_appointments/practitioner_availability_page.py new file mode 100644 index 00000000..53b6828b --- /dev/null +++ b/pages/screening_practitioner_appointments/practitioner_availability_page.py @@ -0,0 +1,60 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class PractitionerAvailabilityPage(BasePage): + """Practitioner Availability Page locators, and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Practitioner Availability - page locators + self.site_id_dropdown = page.locator("#UI_SITE_ID") + self.screening_practitioner_dropdown = page.locator("#UI_PRACTITIONER_ID") + self.calendar_button = page.get_by_role("button", name="Calendar") + self.show_button = page.get_by_role("button", name="Show") + self.time_from_text_field = page.get_by_role("textbox", name="From:") + self.time_to_text_field = page.get_by_role("textbox", name="To:") + self.calculate_slots_button = page.get_by_role("button", name="Calculate Slots") + self.number_of_weeks_text_field = page.locator("#FOR_WEEKS") + self.save_button = page.get_by_role("button", name="Save") + + def select_site_dropdown_option(self, site_to_use: str) -> None: + """Selects the site from the dropdown list.""" + self.site_id_dropdown.select_option(label=site_to_use) + + def select_practitioner_dropdown_option(self, practitioner: str) -> None: + """Selects the practitioner from the dropdown list.""" + self.screening_practitioner_dropdown.select_option(label=practitioner) + + def click_calendar_button(self) -> None: + """Clicks the calendar button to open the calendar picker.""" + self.click(self.calendar_button) + + def click_show_button(self) -> None: + """Clicks the show button to display available slots.""" + self.click(self.show_button) + + def enter_start_time(self, start_time: str) -> None: + """Enters a given start time in the 'time from text' field.""" + self.time_from_text_field.fill(start_time) + + def enter_end_time(self, end_time: str) -> None: + """Enters a given end time in the 'time to text' field.""" + self.time_to_text_field.fill(end_time) + + def click_calculate_slots_button(self) -> None: + """Clicks the calculate slots button to calculate available slots.""" + self.click(self.calculate_slots_button) + + def enter_number_of_weeks(self, weeks: str) -> None: + """Enters a given number of weeks in the 'number of weeks text' field.""" + self.number_of_weeks_text_field.fill(weeks) + + def click_save_button(self) -> None: + """Clicks the save button.""" + self.click(self.save_button) + + def slots_updated_message_is_displayed(self, message: str) -> None: + """Checks if the slots updated message is displayed.""" + expect(self.page.get_by_text(message)).to_be_visible() diff --git a/pages/screening_practitioner_appointments/screening_practitioner_appointments_page.py b/pages/screening_practitioner_appointments/screening_practitioner_appointments_page.py new file mode 100644 index 00000000..83a8c197 --- /dev/null +++ b/pages/screening_practitioner_appointments/screening_practitioner_appointments_page.py @@ -0,0 +1,45 @@ +from playwright.sync_api import Page +from pages.base_page import BasePage + + +class ScreeningPractitionerAppointmentsPage(BasePage): + """Screening Practitioner Appointments Page locators and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # ScreeningPractitionerAppointments Page + self.log_in_page = self.page.get_by_role("button", name="Log in") + self.view_appointments_page = self.page.get_by_role( + "link", name="View appointments" + ) + self.patients_that_require_page = self.page.get_by_role( + "link", name="Patients that Require" + ) + # Greyed out links (not clickable due to user role permissions) + self.patients_that_require_colonoscopy_assessment_appointments_bowel_scope_link = self.page.get_by_text( + "Patients that Require Colonoscopy Assessment Appointments - Bowel Scope" + ) + self.patients_that_require_surveillance_appointment_link = page.get_by_text( + "Patients that Require Surveillance Appointments" + ) + self.patients_that_require_post = page.get_by_text( + "Patients that Require Post-" + ) + self.set_availability_link = page.get_by_text("Set Availability") + + def go_to_log_in_page(self) -> None: + """Click on the Log in button to navigate to the login page.""" + self.click(self.log_in_page) + + def go_to_view_appointments_page(self) -> None: + """Click on the View appointments link to navigate to the appointments page.""" + self.click(self.view_appointments_page) + + def go_to_patients_that_require_page(self) -> None: + """Click on the 'Patients that Require' link to navigate to the 'patients that require' page.""" + self.click(self.patients_that_require_page) + + def go_to_set_availability_page(self) -> None: + """Click on the Set Availability link to navigate to the set availability page.""" + self.click(self.set_availability_link) diff --git a/pages/screening_practitioner_appointments/screening_practitioner_day_view_page.py b/pages/screening_practitioner_appointments/screening_practitioner_day_view_page.py new file mode 100644 index 00000000..d3d9f332 --- /dev/null +++ b/pages/screening_practitioner_appointments/screening_practitioner_day_view_page.py @@ -0,0 +1,25 @@ +from playwright.sync_api import Page +from pages.base_page import BasePage + + +class ScreeningPractitionerDayViewPage(BasePage): + """Screening Practitioner Day View Page locators and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Screening Practitioner Day View - page locators + self.calendar_button = page.get_by_role("button", name="Calendar") + self.practitioner_dropdown = self.page.locator("#UI_PRACTITIONER_NDV") + + def click_calendar_button(self) -> None: + """Click on the Calendar button to open the calendar picker.""" + self.click(self.calendar_button) + + def click_patient_link(self, patient_name: str) -> None: + """Click on the patient link to navigate to the patient's details page.""" + self.click(self.page.get_by_role("link", name=patient_name)) + + def select_practitioner_dropdown_option(self, practitioner: str | list) -> None: + """Select given practitioner from the practitioner dropdown list""" + self.practitioner_dropdown.select_option(label=practitioner) diff --git a/pages/screening_practitioner_appointments/set_availability_page.py b/pages/screening_practitioner_appointments/set_availability_page.py new file mode 100644 index 00000000..819da877 --- /dev/null +++ b/pages/screening_practitioner_appointments/set_availability_page.py @@ -0,0 +1,18 @@ +from playwright.sync_api import Page +from pages.base_page import BasePage + + +class SetAvailabilityPage(BasePage): + """Set Availability Page locators and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Set Availability - page locators + self.practitioner_availability_link = page.get_by_role( + "link", name="Practitioner Availability -" + ) + + def go_to_practitioner_availability_page(self) -> None: + """Navigate to the Practitioner Availability page.""" + self.click(self.practitioner_availability_link) diff --git a/pages/screening_subject_search/__init__.py b/pages/screening_subject_search/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pages/screening_subject_search/advance_fobt_screening_episode_page.py b/pages/screening_subject_search/advance_fobt_screening_episode_page.py new file mode 100644 index 00000000..7e1f6cb7 --- /dev/null +++ b/pages/screening_subject_search/advance_fobt_screening_episode_page.py @@ -0,0 +1,101 @@ +from playwright.sync_api import Page, expect, Locator +from pages.base_page import BasePage +import logging +import pytest + + +class AdvanceFOBTScreeningEpisodePage(BasePage): + """Advance FOBT Screening Episode Page locators, and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Advance FOBT Screening Episode - page locators + self.suitable_for_endoscopic_test_button = self.page.get_by_role( + "button", name="Suitable for Endoscopic Test" + ) + self.calendar_button = self.page.get_by_role("button", name="Calendar") + self.test_type_dropdown = self.page.locator("#UI_EXT_TEST_TYPE_2233") + self.invite_for_diagnostic_test_button = self.page.get_by_role( + "button", name="Invite for Diagnostic Test >>" + ) + self.attend_diagnostic_test_button = self.page.get_by_role( + "button", name="Attend Diagnostic Test" + ) + self.other_post_investigation_button = self.page.get_by_role( + "button", name="Other Post-investigation" + ) + self.record_other_post_investigation_contact_button = self.page.get_by_role( + "button", name="Record other post-" + ) + self.enter_diagnostic_test_outcome_button = self.page.get_by_role( + "button", name="Enter Diagnostic Test Outcome" + ) + self.handover_into_symptomatic_care_button = self.page.get_by_role( + "button", name="Handover into Symptomatic Care" + ) + self.record_diagnosis_date_button = self.page.get_by_role( + "button", name="Record Diagnosis Date" + ) + + def click_suitable_for_endoscopic_test_button(self) -> None: + """Click the 'Suitable for Endoscopic Test' button.""" + AdvanceFOBTScreeningEpisodePage(self.page).safe_accept_dialog( + self.suitable_for_endoscopic_test_button + ) + + def click_calendar_button(self) -> None: + """Click the calendar button to open the calendar picker.""" + self.click(self.calendar_button) + + def select_test_type_dropdown_option(self, text: str) -> None: + """Select the test type from the dropdown.""" + self.test_type_dropdown.select_option(label=text) + + def click_invite_for_diagnostic_test_button(self) -> None: + """Click the 'Invite for Diagnostic Test' button.""" + AdvanceFOBTScreeningEpisodePage(self.page).safe_accept_dialog( + self.invite_for_diagnostic_test_button + ) + + def click_attend_diagnostic_test_button(self) -> None: + """Click the 'Attend Diagnostic Test' button.""" + self.click(self.attend_diagnostic_test_button) + + def click_other_post_investigation_button(self) -> None: + """Click the 'Other Post-investigation' button.""" + AdvanceFOBTScreeningEpisodePage(self.page).safe_accept_dialog( + self.other_post_investigation_button + ) + + def get_latest_event_status_cell(self, latest_event_status: str) -> Locator: + """Get the cell containing the latest event status.""" + return self.page.get_by_role("cell", name=latest_event_status, exact=True) + + def verify_latest_event_status_value(self, latest_event_status: str) -> None: + """Verify that the latest event status value is visible.""" + logging.info(f"Verifying subject has the status: {latest_event_status}") + latest_event_status_cell = self.get_latest_event_status_cell( + latest_event_status + ) + try: + expect(latest_event_status_cell).to_be_visible() + logging.info(f"Subject has the status: {latest_event_status}") + except Exception: + pytest.fail(f"Subject does not have the status: {latest_event_status}") + + def click_record_other_post_investigation_contact_button(self) -> None: + """Click the 'Record other post-investigation contact' button.""" + self.click(self.record_other_post_investigation_contact_button) + + def click_enter_diagnostic_test_outcome_button(self) -> None: + """Click the 'Enter Diagnostic Test Outcome' button.""" + self.click(self.enter_diagnostic_test_outcome_button) + + def click_handover_into_symptomatic_care_button(self) -> None: + """Click the 'Handover Into Symptomatic Care' button.""" + self.click(self.handover_into_symptomatic_care_button) + + def click_record_diagnosis_date_button(self) -> None: + """Click the 'Record Diagnosis Date' button.""" + self.click(self.record_diagnosis_date_button) diff --git a/pages/screening_subject_search/attend_diagnostic_test_page.py b/pages/screening_subject_search/attend_diagnostic_test_page.py new file mode 100644 index 00000000..f204af8b --- /dev/null +++ b/pages/screening_subject_search/attend_diagnostic_test_page.py @@ -0,0 +1,28 @@ +from playwright.sync_api import Page +from pages.base_page import BasePage + + +class AttendDiagnosticTestPage(BasePage): + """Attend Diagnostic Test Page locators and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Advance Diagnostic Test - page locators + self.actual_type_of_test_dropdown = self.page.locator( + "#UI_CONFIRMED_TYPE_OF_TEST" + ) + self.calendar_button = self.page.get_by_role("button", name="Calendar") + self.save_button = self.page.get_by_role("button", name="Save") + + def select_actual_type_of_test_dropdown_option(self, text: str) -> None: + """Select the actual type of test from the dropdown.""" + self.actual_type_of_test_dropdown.select_option(label=text) + + def click_calendar_button(self) -> None: + """Click the calendar button to open the calendar picker.""" + self.click(self.calendar_button) + + def click_save_button(self) -> None: + """Click the 'Save' button.""" + self.click(self.save_button) diff --git a/pages/screening_subject_search/contact_with_patient_page.py b/pages/screening_subject_search/contact_with_patient_page.py new file mode 100644 index 00000000..e2714038 --- /dev/null +++ b/pages/screening_subject_search/contact_with_patient_page.py @@ -0,0 +1,86 @@ +from playwright.sync_api import Page +from pages.base_page import BasePage + + +class ContactWithPatientPage(BasePage): + """ + ContactWithPatientPage class for interacting with 'Contact With Patient' page elements. + """ + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + + # Contact With Patient - Page Locators + self.contact_direction_dropdown = self.page.locator("#UI_DIRECTION") + self.contact_made_between_patient_and_dropdown = self.page.locator( + "#UI_CALLER_ID" + ) + self.calendar_button = self.page.get_by_role("button", name="Calendar") + self.start_time_field = self.page.locator("#UI_START_TIME") + self.end_time_field = self.page.locator("#UI_END_TIME") + self.discussion_record_text_field = self.page.locator("#UI_COMMENT_ID") + self.outcome_dropdown = self.page.locator("#UI_OUTCOME") + self.save_button = self.page.get_by_role("button", name="Save") + + def select_direction_dropdown_option(self, direction: str) -> None: + """ + Select an option from the 'Direction' dropdown by its label. + + Args: + direction (str): The label of the direction option to select. + """ + self.contact_direction_dropdown.select_option(label=direction) + + def select_caller_id_dropdown_index_option(self, index_value: int) -> None: + """ + Select an option from the 'Caller ID' dropdown by its index. + + Args: + index_value (int): The index of the caller ID option to select. + """ + self.contact_made_between_patient_and_dropdown.select_option(index=index_value) + + def click_calendar_button(self) -> None: + """Click the 'Calendar' button to open the calendar picker.""" + self.click(self.calendar_button) + + def enter_start_time(self, start_time: str) -> None: + """ + Enter a value into the 'Start Time' field. + + Args: + start_time (str): The start time to enter into the field. + """ + self.start_time_field.fill(start_time) + + def enter_end_time(self, end_time: str) -> None: + """ + Enter a value into the 'End Time' field. + + Args: + end_time (str): The end time to enter into the field. + """ + self.end_time_field.fill(end_time) + + def enter_discussion_record_text(self, value: str) -> None: + """ + Enter text into the 'Discussion Record' field. + + Args: + value (str): The text to enter into the discussion record field. + """ + self.discussion_record_text_field.fill(value) + + def select_outcome_dropdown_option(self, outcome: str) -> None: + """ + Select an option from the 'Outcome' dropdown by its label. + + Args: + outcome (str): The label of the outcome option to select. + """ + self.outcome_dropdown.select_option(label=outcome) + + def click_save_button(self) -> None: + """Click the 'Save' button to save the contact with patient form.""" + self.click(self.save_button) diff --git a/pages/screening_subject_search/diagnostic_test_outcome_page.py b/pages/screening_subject_search/diagnostic_test_outcome_page.py new file mode 100644 index 00000000..c2274f49 --- /dev/null +++ b/pages/screening_subject_search/diagnostic_test_outcome_page.py @@ -0,0 +1,46 @@ +from playwright.sync_api import Page, expect, Locator +from pages.base_page import BasePage +from enum import StrEnum + + +class DiagnosticTestOutcomePage(BasePage): + """Diagnostic Test Outcome Page locators and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Diagnostic Test Outcome- page locators + self.test_outcome_dropdown = self.page.get_by_label( + "Outcome of Diagnostic Test" + ) + self.save_button = self.page.get_by_role("button", name="Save") + + def verify_diagnostic_test_outcome(self, outcome_name: str) -> None: + """ + Verify that the diagnostic test outcome is visible. + + Args: + outcome_name (str): The accessible name or visible text of the test outcome cell to verify. + """ + expect(self.page.get_by_role("cell", name=outcome_name).nth(1)).to_be_visible() + + def select_test_outcome_option(self, option: str) -> None: + """Select an option from the Outcome of Diagnostic Test dropdown. + + Args: + option (str): option (str): The option to select from the Outcome Of Diagnostic Test options. + """ + self.test_outcome_dropdown.select_option(option) + + def click_save_button(self) -> None: + """Click the 'Save' button.""" + self.click(self.save_button) + + +class OutcomeOfDiagnosticTest(StrEnum): + """Enum for outcome of diagnostic test options.""" + + FAILED_TEST_REFER_ANOTHER = "20363" + REFER_SYMPTOMATIC = "20366" + REFER_SURVEILLANCE = "20365" + INVESTIGATION_COMPLETE = "20360" diff --git a/pages/screening_subject_search/episode_events_and_notes_page.py b/pages/screening_subject_search/episode_events_and_notes_page.py new file mode 100644 index 00000000..530fed72 --- /dev/null +++ b/pages/screening_subject_search/episode_events_and_notes_page.py @@ -0,0 +1,17 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage + + +class EpisodeEventsAndNotesPage(BasePage): + """Episode Events and Notes Page locators, and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # List of episode events and notes - page locators + + def expected_episode_event_is_displayed(self, event_description: str) -> None: + """Check if the expected episode event is displayed on the page.""" + expect( + self.page.get_by_role("cell", name=event_description, exact=True) + ).to_be_visible() diff --git a/pages/screening_subject_search/handover_into_symptomatic_care_page.py b/pages/screening_subject_search/handover_into_symptomatic_care_page.py new file mode 100644 index 00000000..d436905c --- /dev/null +++ b/pages/screening_subject_search/handover_into_symptomatic_care_page.py @@ -0,0 +1,54 @@ +from playwright.sync_api import Page +from pages.base_page import BasePage +from datetime import datetime + +class HandoverIntoSymptomaticCarePage(BasePage): + """ + HandoverIntoSymptomaticCarePage class for interacting with the 'Handover Into Symptomatic Care' page elements. + """ + def __init__(self, page: Page): + self.page = page + self.referral_dropdown = self.page.get_by_label("Referral") + self.calendar_button = self.page.get_by_role("button", name="Calendar") + self.consultant_link = self.page.locator("#UI_NS_CONSULTANT_PIO_SELECT_LINK") + self.notes_textbox = self.page.get_by_role("textbox", name="Notes") + self.save_button = self.page.get_by_role("button", name="Save") + + def select_referral_dropdown_option(self, value: str) -> None: + """ + Select a given option from the Referral dropdown. + + Args: + value (str): The value of the option you want to select + """ + self.referral_dropdown.select_option(value) + + def click_calendar_button(self) -> None: + """Click the calendar button to open the calendar picker.""" + self.click(self.calendar_button) + + def select_consultant(self, value: str) -> None: + """ + Select a consultant from the consultant dropdown using the given value. + + Args: + value (str): The value attribute of the consultant option to select. + """ + self.consultant_link.click() + option_locator = self.page.locator(f'[value="{value}"]:visible') + option_locator.wait_for(state="visible") + self.click(option_locator) + + def fill_notes(self, notes: str) -> None: + """ + Fill the 'Notes' textbox with the provided text. + + Args: + notes (str): The text to enter into the notes textbox. + """ + self.notes_textbox.click() + self.notes_textbox.fill(notes) + + def click_save_button(self) -> None: + """Click the save button to save the changes.""" + self.safe_accept_dialog(self.save_button) diff --git a/pages/screening_subject_search/record_diagnosis_date_page.py b/pages/screening_subject_search/record_diagnosis_date_page.py new file mode 100644 index 00000000..efde29ce --- /dev/null +++ b/pages/screening_subject_search/record_diagnosis_date_page.py @@ -0,0 +1,29 @@ +from playwright.sync_api import Page +from pages.base_page import BasePage +from datetime import datetime +from utils.calendar_picker import CalendarPicker + + +class RecordDiagnosisDatePage(BasePage): + """Record Diagnosis Date Page locators, and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Record Diagnosis Date - page locators + self.diagnosis_date_field = self.page.locator("#diagnosisDate") + self.save_button = self.page.get_by_role("button", name="Save") + + def enter_date_in_diagnosis_date_field(self, date: datetime) -> None: + """ + Enters a date in the diagnosis date field. + Args: + date (datetime): The date to enter in the field. + """ + self.click(self.diagnosis_date_field) + CalendarPicker(self.page).v2_calendar_picker(date) + self.diagnosis_date_field.press("Enter") + + def click_save_button(self) -> None: + """Clicks the save button.""" + self.click(self.save_button) diff --git a/pages/screening_subject_search/subject_demographic_page.py b/pages/screening_subject_search/subject_demographic_page.py new file mode 100644 index 00000000..8fb26036 --- /dev/null +++ b/pages/screening_subject_search/subject_demographic_page.py @@ -0,0 +1,186 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage +from datetime import datetime +from utils.calendar_picker import CalendarPicker + + +class SubjectDemographicPage(BasePage): + """Subject Demographic Page locators, and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Subject Demographic - page filters + self.forename_field = self.page.get_by_role("textbox", name="Forename") + self.surname_field = self.page.get_by_role("textbox", name="Surname") + self.postcode_field = self.page.get_by_role("textbox", name="Postcode") + self.dob_field = self.page.get_by_role("textbox", name="Date of Birth") + self.update_subject_data_button = self.page.get_by_role( + "button", name="Update Subject Data" + ) + self.temporary_address_show_link = ( + self.page.locator("font") + .filter(has_text="Temporary Address show") + .get_by_role("link") + ) + self.temporary_address_valid_from_calendar_button = self.page.locator( + "#UI_SUBJECT_ALT_FROM_0_LinkOrButton" + ) + self.temporary_address_valid_to_calendar_button = self.page.locator( + "#UI_SUBJECT_ALT_TO_0_LinkOrButton" + ) + self.temporary_address_valid_from_text_box = self.page.get_by_role( + "textbox", name="Valid from" + ) + self.temporary_address_valid_to_text_box = self.page.get_by_role( + "textbox", name="Valid to" + ) + self.temporary_address_address_line_1 = self.page.locator( + "#UI_SUBJECT_ALT_ADDR1_0" + ) + self.temporary_address_address_line_2 = self.page.locator( + "#UI_SUBJECT_ALT_ADDR2_0" + ) + self.temporary_address_address_line_3 = self.page.locator( + "#UI_SUBJECT_ALT_ADDR3_0" + ) + self.temporary_address_address_line_4 = self.page.locator( + "#UI_SUBJECT_ALT_ADDR4_0" + ) + self.temporary_address_address_line_5 = self.page.locator( + "#UI_SUBJECT_ALT_ADDR5_0" + ) + self.temporary_address_postcode = self.page.locator( + "#UI_SUBJECT_ALT_POSTCODE_0" + ) + + def is_forename_filled(self) -> bool: + """ + Checks if the forename textbox contains a value. + + Returns: + True if the textbox has a non-empty value, False otherwise + """ + forename_value = self.forename_field.input_value() + return bool(forename_value.strip()) + + def is_surname_filled(self) -> bool: + """ + Checks if the surname textbox contains a value. + + Returns: + True if the textbox has a non-empty value, False otherwise + """ + surname_value = self.surname_field.input_value() + return bool(surname_value.strip()) + + def is_postcode_filled(self) -> bool: + """ + Checks if the postcode textbox contains a value. + + Returns: + True if the textbox has a non-empty value, False otherwise + """ + postcode_value = self.postcode_field.input_value() + return bool(postcode_value.strip()) + + def fill_forename_input(self, name: str) -> None: + """ + Enters a value into the forename input textbox + + Args: + name (str): The name you want to enter + """ + self.forename_field.fill(name) + + def fill_surname_input(self, name: str) -> None: + """ + Enters a value into the surname input textbox + + Args: + name (str): The name you want to enter + """ + self.surname_field.fill(name) + + def fill_dob_input(self, date: datetime) -> None: + """ + Enters a value into the date of birth input textbox + + Args: + date (datetime): The date you want to enter + """ + if date is None: + raise ValueError("The 'date' argument cannot be None") + CalendarPicker(self.page).calendar_picker_ddmmyyyy(date, self.dob_field) + + def fill_postcode_input(self, postcode: str) -> None: + """ + Enters a value into the postcode input textbox + + Args: + postcode (str): The postcode you want to enter + """ + self.postcode_field.fill(postcode) + + def click_update_subject_data_button(self) -> None: + """Clicks on the 'Update Subject Data' button""" + self.click(self.update_subject_data_button) + + def get_dob_field_value(self) -> str: + """ + Returns the value in the date of birth input textbox + + Returns: + str: The subject's date of birth as a string + """ + return self.dob_field.input_value() + + def update_temporary_address(self, dict: dict) -> None: + """ + Updates the temporary address fields with the provided dictionary values. + Args: + dict (dict): A dictionary containing the temporary address details. + Expected keys: 'valid_from', 'valid_to', 'address_line_1', + 'address_line_2', 'address_line_3', 'address_line_4', 'address_line_5'. + """ + # Click the link to show the temporary address fields + if self.temporary_address_show_link.is_visible(): + # If the link is visible, click it to show the temporary address fields + self.click(self.temporary_address_show_link) + + # Update the valid from date + if "valid_from" in dict: + if dict["valid_from"] is None: + self.temporary_address_valid_from_text_box.fill("") + else: + CalendarPicker(self.page).calendar_picker_ddmmyyyy( + dict["valid_from"], self.temporary_address_valid_from_text_box + ) + + # Update the valid to date + if "valid_to" in dict: + if dict["valid_to"] is None: + self.temporary_address_valid_to_text_box.fill("") + else: + CalendarPicker(self.page).calendar_picker_ddmmyyyy( + dict["valid_to"], self.temporary_address_valid_to_text_box + ) + + # Fill in the address lines + if "address_line_1" in dict: + self.temporary_address_address_line_1.fill(dict["address_line_1"]) + if "address_line_2" in dict: + self.temporary_address_address_line_2.fill(dict["address_line_2"]) + if "address_line_3" in dict: + self.temporary_address_address_line_3.fill(dict["address_line_3"]) + if "address_line_4" in dict: + self.temporary_address_address_line_4.fill(dict["address_line_4"]) + if "address_line_5" in dict: + self.temporary_address_address_line_5.fill(dict["address_line_5"]) + + # Fill in the postcode + if "postcode" in dict: + self.temporary_address_postcode.fill(dict["postcode"]) + + # Click the update subject data button to save changes + self.update_subject_data_button.click() diff --git a/pages/screening_subject_search/subject_events_notes.py b/pages/screening_subject_search/subject_events_notes.py new file mode 100644 index 00000000..aa69c699 --- /dev/null +++ b/pages/screening_subject_search/subject_events_notes.py @@ -0,0 +1,147 @@ +from playwright.sync_api import Page, Locator +from pages.base_page import BasePage +from enum import StrEnum +import logging +import pytest +from utils.table_util import TableUtils + + +class SubjectEventsNotes(BasePage): + """Subject Events Notes Page locators, and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + self.table_utils = TableUtils( + page, "#displayRS" + ) # Initialize TableUtils for the table with id="displayRS" + # Subject Events Notes - page filters + self.additional_care_note_checkbox = self.page.get_by_label( + "Additional Care Needs Note" + ) + self.subject_note_checkbox = self.page.get_by_label("Subject Note") + self.kit_note_checkbox = self.page.get_by_label("Kit Note") + self.note_title = self.page.get_by_label("Note Title") + self.additional_care_note_type = self.page.locator("#UI_ADDITIONAL_CARE_NEED") + self.notes_upto_500_char = self.page.get_by_label("Notes (up to 500 char)") + self.update_notes_button = self.page.get_by_role("button", name="Update Notes") + self.note_type = self.page.locator("#UI_ADDITIONAL_CARE_NEED_FILTER") + self.note_status = self.page.locator( + "//table[@id='displayRS']/tbody/tr[2]/td[3]/select" + ) + self.episode_note_status = self.page.locator( + "//table[@id='displayRS']/tbody/tr[2]/td[4]/select" + ) + + def select_additional_care_note(self) -> None: + """Selects the 'Additional Care Needs Note' checkbox.""" + self.additional_care_note_checkbox.check() + + def select_subject_note(self) -> None: + """Selects the 'subject note' checkbox.""" + self.subject_note_checkbox.check() + + def select_kit_note(self) -> None: + """Selects the 'kit note' checkbox.""" + self.kit_note_checkbox.check() + + def select_additional_care_note_type(self, option: str) -> None: + """Selects an option from the 'Additional Care Note Type' dropdown. + + Args: + option (AdditionalCareNoteTypeOptions): The option to select from the dropdown. + Use one of the predefined values from the + AdditionalCareNoteTypeOptions enum, such as: + - AdditionalCareNoteTypeOptions.LEARNING_DISABILITY + - AdditionalCareNoteTypeOptions.SIGHT_DISABILITY + - AdditionalCareNoteTypeOptions.HEARING_DISABILITY + - AdditionalCareNoteTypeOptions.MOBILITY_DISABILITY + - AdditionalCareNoteTypeOptions.MANUAL_DEXTERITY + - AdditionalCareNoteTypeOptions.SPEECH_DISABILITY + - AdditionalCareNoteTypeOptions.CONTINENCE_DISABILITY + - AdditionalCareNoteTypeOptions.LANGUAGE + - AdditionalCareNoteTypeOptions.OTHER + """ + self.additional_care_note_type.select_option(option) + + def select_note_type(self, option: str) -> None: + """ + Selects a note type from the dropdown menu. + + Args: + option (str): The value of the option to select from the dropdown. + """ + self.note_type.select_option(option) + + def select_note_status(self, option: str) -> None: + """ + Selects a note status from the dropdown menu. + + Args: + option (str): The value of the option to select from the dropdown. + """ + self.note_status.select_option(option) + + def fill_note_title(self, title: str) -> None: + """Fills the title field with the provided text.""" + self.note_title.fill(title) + + def fill_notes(self, notes: str) -> None: + """Fills the notes field with the provided text.""" + self.notes_upto_500_char.fill(notes) + + def accept_dialog_and_update_notes(self) -> None: + """Clicks the 'Update Notes' button and handles the dialog by clicking 'OK'.""" + self.page.once("dialog", lambda dialog: dialog.accept()) + self.update_notes_button.click() + + def accept_dialog_and_add_replacement_note(self) -> None: + """ + Dismisses the dialog and clicks the 'Add Replacement Note' button. + """ + self.page.once("dialog", lambda dialog: dialog.accept()) + self.page.get_by_role("button", name="Add Replacement Note").click() + + def get_title_and_note_from_row(self, row_number: int = 0) -> dict: + """ + Extracts title and note from a specific row's 'Notes' column using dynamic column index. + """ + cell_text = self.table_utils.get_cell_value("Notes", row_number) + lines = cell_text.split("\n\n") + title = lines[0].strip() if len(lines) > 0 else "" + note = lines[1].strip() if len(lines) > 1 else "" + logging.info( + f"Extracted title: '{title}' and note: '{note}' from row {row_number}" + ) + return {"title": title, "note": note} + + +class AdditionalCareNoteTypeOptions(StrEnum): + """Enum for AdditionalCareNoteTypeOptions.""" + + LEARNING_DISABILITY = "4120" + SIGHT_DISABILITY = "4121" + HEARING_DISABILITY = "4122" + MOBILITY_DISABILITY = "4123" + MANUAL_DEXTERITY = "4124" + SPEECH_DISABILITY = "4125" + CONTINENCE_DISABILITY = "4126" + LANGUAGE = "4128" + OTHER = "4127" + + +class NotesOptions(StrEnum): + """Enum for NoteTypeOptions.""" + + SUBJECT_NOTE = "4111" + KIT_NOTE = "308015" + ADDITIONAL_CARE_NOTE = "4112" + EPISODE_NOTE = "4110" + + +class NotesStatusOptions(StrEnum): + """Enum for NoteStatusOptions.""" + + ACTIVE = "4100" + OBSOLETE = "4101" + INVALID = "4102" diff --git a/pages/screening_subject_search/subject_screening_search_page.py b/pages/screening_subject_search/subject_screening_search_page.py new file mode 100644 index 00000000..9b63d361 --- /dev/null +++ b/pages/screening_subject_search/subject_screening_search_page.py @@ -0,0 +1,191 @@ +from playwright.sync_api import Page, expect +from pages.base_page import BasePage +from enum import Enum +from utils.calendar_picker import CalendarPicker + + +class SubjectScreeningPage(BasePage): + """Subject Screening Page locators and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + self.results_table_locator = "table#subject-search-results" + + # Subject Search Criteria - page filters + self.episodes_filter = self.page.get_by_role("radio", name="Episodes") + self.demographics_filter = self.page.get_by_role("radio", name="Demographics") + self.datasets_filter = self.page.get_by_role("radio", name="Datasets") + self.nhs_number_filter = self.page.get_by_role("textbox", name="NHS Number") + self.nhs_number_input = self.page.get_by_label("NHS Number") + self.surname_filter = self.page.locator("#A_C_Surname") + self.soundex_filter = self.page.get_by_role("checkbox", name="Use soundex") + self.forename_filter = self.page.get_by_role("textbox", name="Forename") + self.date_of_birth_filter = self.page.locator("#A_C_DOB_From") + self.data_of_birth_range_filter = self.page.get_by_role( + "textbox", name="(for a date range, enter a to" + ) + self.postcode_filter = self.page.get_by_role("textbox", name="Postcode") + self.episode_closed_date_filter = self.page.get_by_role( + "textbox", name="Episode Closed Date" + ) + self.kit_batch_number_filter = self.page.get_by_role( + "textbox", name="Kit Batch Number" + ) + self.kit_number_filter = self.page.get_by_role("textbox", name="Kit Number") + self.fit_device_id_filter = self.page.get_by_role( + "textbox", name="FIT Device ID" + ) + self.laboratory_name_filter = self.page.get_by_role( + "textbox", name="Laboratory Name" + ) + self.laboratory_test_date_filter = self.page.get_by_role( + "textbox", name="Laboratory Test Date" + ) + self.diagnostic_test_actual_date_filter = self.page.get_by_role( + "textbox", name="Diagnostic Test Actual Date" + ) + self.search_button = self.page.get_by_role("button", name="Search") + self.clear_filters_button = self.page.get_by_role( + "button", name="Clear Filters" + ) + self.appropriate_code_filter = self.page.get_by_label("Appropriate Code") + self.gp_practice_in_ccg_filter = self.page.get_by_label("GP Practice in CCG") + + self.select_screening_status = self.page.locator("#A_C_ScreeningStatus") + self.select_episode_status = self.page.locator("#A_C_EpisodeStatus") + self.select_search_area = self.page.locator("#A_C_SEARCH_DOMAIN") + + self.dob_calendar_picker = self.page.locator("#A_C_DOB_From_LinkOrButton") + + def click_clear_filters_button(self) -> None: + """Click the 'Clear Filters' button.""" + self.click(self.clear_filters_button) + + def click_search_button(self) -> None: + """Click the 'Search' button.""" + self.click(self.search_button) + + def click_episodes_filter(self) -> None: + """Click the 'Episodes' filter.""" + self.episodes_filter.check() + + def click_demographics_filter(self) -> None: + """Click the 'Demographics' filter.""" + self.demographics_filter.check() + + def click_datasets_filter(self) -> None: + """Click the 'Datasets' filter.""" + self.datasets_filter.check() + + def click_nhs_number_filter(self) -> None: + """Click the 'NHS Number' filter.""" + self.click(self.nhs_number_filter) + + def click_surname_filter(self) -> None: + """Click the 'Surname' filter.""" + self.click(self.surname_filter) + + def click_soundex_filter(self) -> None: + """Click the 'Use soundex' filter.""" + self.soundex_filter.check() + + def click_forename_filter(self) -> None: + """Click the 'Forename' filter.""" + self.click(self.forename_filter) + + def click_date_of_birth_filter(self) -> None: + """Click the 'Date of Birth' filter.""" + self.click(self.date_of_birth_filter) + + def click_date_of_birth_range_filter(self) -> None: + """Click the 'Date of Birth Range' filter.""" + self.click(self.data_of_birth_range_filter) + + def click_postcode_filter(self) -> None: + """Click the 'Postcode' filter.""" + self.click(self.postcode_filter) + + def click_episodes_closed_date_filter(self) -> None: + """Click the 'Episode Closed Date' filter.""" + self.click(self.episode_closed_date_filter) + + def click_kit_batch_number_filter(self) -> None: + """Click the 'Kit Batch Number' filter.""" + self.click(self.kit_batch_number_filter) + + def click_kit_number_filter(self) -> None: + """Click the 'Kit Number' filter.""" + self.click(self.kit_number_filter) + + def click_fit_device_id_filter(self) -> None: + """Click the 'FIT Device ID' filter.""" + self.click(self.fit_device_id_filter) + + def click_laboratory_name_filter(self) -> None: + """Click the 'Laboratory Name' filter.""" + self.click(self.laboratory_name_filter) + + def click_laboratory_test_date_filter(self) -> None: + """Click the 'Laboratory Test Date' filter.""" + self.click(self.laboratory_test_date_filter) + + def click_diagnostic_test_actual_date_filter(self) -> None: + """Click the 'Diagnostic Test Actual Date' filter.""" + self.click(self.diagnostic_test_actual_date_filter) + + def select_screening_status_options(self, option: str) -> None: + """Select a given option from the Screening Status dropdown.""" + self.select_screening_status.select_option(option) + + def select_episode_status_option(self, option: str) -> None: + """Select a given option from the Episode Status dropdown.""" + self.select_episode_status.select_option(option) + + def select_search_area_option(self, option: str) -> None: + """Select a given option from the Search Area dropdown.""" + self.select_search_area.select_option(option) + + def select_dob_using_calendar_picker(self, date) -> None: + """Select a date using the calendar picker for the Date of Birth filter.""" + self.click(self.dob_calendar_picker) + CalendarPicker(self.page).v1_calender_picker(date) + + def verify_date_of_birth_filter_input(self, expected_text: str) -> None: + """Verifies that the Date of Birth filter input field has the expected value.""" + expect(self.date_of_birth_filter).to_have_value(expected_text) + + +class ScreeningStatusSearchOptions(Enum): + """Enum for Screening Status Search Options""" + + CALL_STATUS = "4001" + INACTIVE_STATUS = "4002" + RECALL_STATUS = "4004" + OPT_IN_STATUS = "4003" + SELF_REFERRAL_STATUS = "4005" + SURVEILLANCE_STATUS = "4006" + SEEKING_FURTHER_DATA_STATUS = "4007" + CEASED_STATUS = "4008" + BOWEL_SCOPE_STATUS = "4009" + LYNCH_SURVEILLANCE_STATUS = "306442" + LYNCH_SELF_REFERRAL_STATUS = "307129" + + +class LatestEpisodeStatusSearchOptions(Enum): + """Enum for Latest Episode Status Search Options""" + + OPEN_PAUSED_STATUS = "1" + CLOSED_STATUS = "2" + NO_EPISODE_STATUS = "3" + + +class SearchAreaSearchOptions(Enum): + """Enum for Search Area Search Options""" + + SEARCH_AREA_HOME_HUB = "01" + SEARCH_AREA_GP_PRACTICE = "02" + SEARCH_AREA_CCG = "03" + SEARCH_AREA_SCREENING_CENTRE = "05" + SEARCH_AREA_OTHER_HUB = "06" + SEARCH_AREA_WHOLE_DATABASE = "07" diff --git a/pages/screening_subject_search/subject_screening_summary_page.py b/pages/screening_subject_search/subject_screening_summary_page.py new file mode 100644 index 00000000..eb5b5cc9 --- /dev/null +++ b/pages/screening_subject_search/subject_screening_summary_page.py @@ -0,0 +1,282 @@ +from playwright.sync_api import Page, expect, Locator +from pages.base_page import BasePage +from enum import Enum +import logging +import pytest + + +class SubjectScreeningSummaryPage(BasePage): + """Subject Screening Summary Page locators, and methods for interacting with the page.""" + + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # Subject Screening Summary - page filters + self.subject_screening_summary = self.page.get_by_role( + "cell", name="Subject Screening Summary", exact=True + ) + self.latest_event_status = self.page.get_by_role( + "cell", name="Latest Event Status", exact=True + ) + self.subjects_events_notes = self.page.get_by_role( + "link", name="Subject Events & Notes" + ) + self.list_episodes = self.page.get_by_role("link", name="List Episodes") + self.episodes_list_expander_icon = self.page.locator("#ID_LINK_EPISODES_img") + self.subject_demographics = self.page.get_by_role( + "link", name="Subject Demographics" + ) + self.datasets = self.page.get_by_role("link", name="Datasets") + self.individual_letters = self.page.get_by_role( + "link", name="Individual Letters" + ) + self.patient_contacts = self.page.get_by_role("link", name="Patient Contacts") + self.more = self.page.get_by_role("link", name="more") + self.change_screening_status = self.page.get_by_label("Change Screening Status") + self.reason = self.page.get_by_label("Reason", exact=True) + self.update_subject_data = self.page.get_by_role( + "button", name="Update Subject Data" + ) + self.close_fobt_screening_episode = self.page.get_by_role( + "button", name="Close FOBT Screening Episode" + ) + self.a_page_to_advance_the_episode = self.page.get_by_text( + "go to a page to Advance the" + ) + self.a_page_to_close_the_episode = self.page.get_by_text( + "go to a page to Close the" + ) + self.display_rs = self.page.locator("#displayRS") + self.first_fobt_episode_link = page.get_by_role( + "link", name="FOBT Screening" + ).first + self.datasets_link = self.page.get_by_role("link", name="Datasets") + self.advance_fobt_screening_episode_button = self.page.get_by_role( + "button", name="Advance FOBT Screening Episode" + ) + self.additional_care_note_link = self.page.get_by_role("link", name="(AN)") + self.temporary_address_icon = self.page.get_by_role( + "link", name="The person has a current" + ) + self.temporary_address_popup = self.page.locator("#idTempAddress") + self.close_button = self.page.get_by_role("img", name="close") + + def wait_for_page_title(self) -> None: + """Waits for the page to be the Subject Screening Summary""" + self.subject_screening_summary.wait_for() + + def verify_result_contains_text(self, text) -> None: + """Verify that the result contains the given text.""" + expect(self.display_rs).to_contain_text(text) + + def verify_subject_search_results_title_subject_screening_summary(self) -> None: + """Verify that the subject search results title contains 'Subject Screening Summary'.""" + self.bowel_cancer_screening_page_title_contains_text( + "Subject Screening Summary" + ) + + def verify_subject_search_results_title_subject_search_results(self) -> None: + """Verify that the subject search results title contains 'Subject Search Results'.""" + self.bowel_cancer_screening_page_title_contains_text("Subject Search Results") + + def get_latest_event_status_cell(self, latest_event_status: str) -> Locator: + """Get the latest event status cell by its name.""" + return self.page.get_by_role("cell", name=latest_event_status, exact=True) + + def verify_subject_screening_summary(self) -> None: + """Verify that the subject screening summary is visible.""" + expect(self.subject_screening_summary).to_be_visible() + + def verify_latest_event_status_header(self) -> None: + """Verify that the latest event status header is visible.""" + expect(self.latest_event_status).to_be_visible() + + def verify_latest_event_status_value(self, latest_event_status: str | list) -> None: + """Verify that the latest event status value is visible.""" + self.wait_for_page_title() + latest_event_status_locator = self.get_visible_status_from_list( + latest_event_status + ) + status = latest_event_status_locator.inner_text() + logging.info(f"Verifying subject has the status: {status}") + try: + expect(latest_event_status_locator).to_be_visible() + logging.info(f"Subject has the status: {status}") + except Exception: + pytest.fail(f"Subject does not have the status: {status}") + + def get_visible_status_from_list(self, latest_event_status) -> Locator: + """ + Get the first visible status from the latest event status string or list. + + Args: + latest_event_status (str | list): The latest event status to check. + + Returns: + Locator: The locator for the first visible status. + """ + if isinstance(latest_event_status, str): + latest_event_status = [latest_event_status] + for status in latest_event_status: + locator = self.page.get_by_role("cell", name=status, exact=True) + if locator.is_visible(): + return locator + raise ValueError("Unable to find any of the listed statuses") + + def click_subjects_events_notes(self) -> None: + """Click on the 'Subject Events & Notes' link.""" + self.click(self.subjects_events_notes) + + def click_list_episodes(self) -> None: + """Click on the 'List Episodes' link.""" + self.click(self.list_episodes) + + def click_subject_demographics(self) -> None: + """Click on the 'Subject Demographics' link.""" + self.click(self.subject_demographics) + + def click_datasets(self) -> None: + """Click on the 'Datasets' link.""" + self.click(self.datasets) + + def click_individual_letters(self) -> None: + """Click on the 'Individual Letters' link.""" + self.click(self.individual_letters) + + def click_patient_contacts(self) -> None: + """Click on the 'Patient Contacts' link.""" + self.click(self.patient_contacts) + + def click_more(self) -> None: + """Click on the 'More' link.""" + self.click(self.more) + + def click_update_subject_data(self) -> None: + """Click on the 'Update Subject Data' button.""" + self.click(self.update_subject_data) + + def click_close_fobt_screening_episode(self) -> None: + """Click on the 'Close FOBT Screening Episode' button.""" + self.click(self.close_fobt_screening_episode) + + def go_to_a_page_to_advance_the_episode(self) -> None: + """Click on the link to go to a page to advance the episode.""" + self.click(self.a_page_to_advance_the_episode) + + def go_to_a_page_to_close_the_episode(self) -> None: + """Click on the link to go to a page to close the episode.""" + self.click(self.a_page_to_close_the_episode) + + def select_change_screening_status(self, option: str) -> None: + """Select the given 'change screening status' option.""" + self.change_screening_status.select_option(option) + + def select_reason(self, option: str) -> None: + """Select the given 'reason' option.""" + self.reason.select_option(option) + + def expand_episodes_list(self) -> None: + """Click on the episodes list expander icon.""" + self.click(self.episodes_list_expander_icon) + + def click_first_fobt_episode_link(self) -> None: + """Click on the first FOBT episode link.""" + self.click(self.first_fobt_episode_link) + + def click_datasets_link(self) -> None: + """Click on the 'Datasets' link.""" + self.click(self.datasets_link) + + def click_advance_fobt_screening_episode_button(self) -> None: + """Click on the 'Advance FOBT Screening Episode' button.""" + logging.info("Advancing the episode") + try: + self.click(self.advance_fobt_screening_episode_button) + logging.info("Episode successfully advanced") + except Exception as e: + pytest.fail(f"Unable to advance the episode: {e}") + + def verify_additional_care_note_visible(self) -> None: + """Verifies that the '(AN)' link is visible.""" + expect(self.additional_care_note_link).to_be_visible() + + def verify_note_link_present(self, note_type_name: str) -> None: + """ + Verifies that the link for the specified note type is visible on the page. + + Args: + note_type_name (str): The name of the note type to check (e.g., 'Additional Care Note', 'Episode Note'). + + Raises: + AssertionError: If the link is not visible on the page. + """ + logging.info(f"Checking if the '{note_type_name}' link is visible.") + note_link_locator = self.page.get_by_role( + "link", name=f"({note_type_name})" + ) # Dynamic locator for the note type link + assert ( + note_link_locator.is_visible() + ), f"'{note_type_name}' link is not visible, but it should be." + logging.info(f"Verified: '{note_type_name}' link is visible.") + + def verify_note_link_not_present(self, note_type_name: str) -> None: + """ + Verifies that the link for the specified note type is not visible on the page. + + Args: + note_type_name (str): The name of the note type to check (e.g., 'Additional Care Note', 'Episode Note'). + + Raises: + AssertionError: If the link is visible on the page. + """ + logging.info(f"Checking if the '{note_type_name}' link is not visible.") + note_link_locator = self.page.get_by_role( + "link", name=f"({note_type_name})" + ) # Dynamic locator for the note type link + assert ( + not note_link_locator.is_visible() + ), f"'{note_type_name}' link is visible, but it should not be." + logging.info(f"Verified: '{note_type_name}' link is not visible.") + def verify_temporary_address_popup_visible(self) -> None: + """Verify that the temporary address popup is visible.""" + try: + expect(self.temporary_address_popup).to_be_visible() + logging.info("Temporary address popup is visible") + except Exception as e: + pytest.fail(f"Temporary address popup is not visible: {e}") + + def click_temporary_address_icon(self) -> None: + """Click on the temporary address icon.""" + self.click(self.temporary_address_icon) + + def verify_temporary_address_icon_visible(self) -> None: + """Verify that the temporary address icon is visible.""" + try: + expect(self.temporary_address_icon).to_be_visible() + logging.info("Temporary address icon is visible") + except Exception as e: + pytest.fail(f"Temporary address icon is not visible when it should be: {e}") + + def verify_temporary_address_icon_not_visible(self) -> None: + """Verify that the temporary address icon is not visible.""" + try: + expect(self.temporary_address_icon).not_to_be_visible() + logging.info("Temporary address icon is not visible as expected") + except Exception as e: + pytest.fail(f"Temporary address icon is visible when it should not be: {e}") + + def click_close_button(self) -> None: + """Click on the close button in the temporary address popup.""" + self.click(self.close_button) + + +class ChangeScreeningStatusOptions(Enum): + """Enum for Change Screening Status options.""" + + SEEKING_FURTHER_DATA = "4007" + + +class ReasonOptions(Enum): + """Enum for Reason options.""" + + UNCERTIFIED_DEATH = "11314" diff --git a/pytest.ini b/pytest.ini index 40fa9d53..f59806a0 100644 --- a/pytest.ini +++ b/pytest.ini @@ -12,14 +12,33 @@ addopts = --json-report-file=test-results/results.json --json-report-omit=collectors --tracing=retain-on-failure + --base-url=https://bcss-bcss-18680-ddc-bcss.k8s-nonprod.texasplatform.uk/ # Allows pytest to identify the base of this project as the pythonpath pythonpath = . # These are the tags that pytest will recognise when using @pytest.mark markers = - example: tests used for example purposes by this blueprint - utils: tests for utility classes provided by this blueprint - branch: tests designed to run at a branch level - main: tests designed to run against the main branch - release: tests designed to run specifically against a release branch + #example: tests used for example purposes by this blueprint + #utils: tests for utility classes provided by this blueprint + #branch: tests designed to run at a branch level + #main: tests designed to run against the main branch + #release: tests designed to run specifically against a release branch + utils: test setup and support methods + utils_local: test setup and support methods locally + smoke: tests designed to run as part of the smokescreen regression test suite + wip: tests that are currently in progress + smokescreen: all compartments to be run as part of the smokescreen + compartment1: only for compartment 1 + compartment2: only for compartment 2 + compartment3: only for compartment 3 + compartment4: only for compartment 4 + compartment5: only for compartment 5 + compartment6: only for compartment 6 + compartment1_plan_creation: to run the plan creation for compartment 1 + vpn_required: for tests that require a VPN connection + regression: tests that are part of the regression test suite + call_and_recall: tests that are part of the call and recall test suite + note_tests: tests that are part of the notes test suite + subject_tests: tests that are part of the subject tests suite + subject_search: tests that are part of the subject search test suite diff --git a/requirements.txt b/requirements.txt index 2d819a10..f6f189be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,10 @@ pytest-playwright>=0.7.0 pytest-html>=4.1.1 pytest-json-report>=1.5.0 pytest-playwright-axe>=4.10.3 -python-dotenv>=1.1.0 +oracledb~=3.0.0 +pandas~=2.2.3 +python-dotenv~=1.1.0 +sqlalchemy>=2.0.38 +jproperties~=2.1.2 +pypdf>=5.3.0 +faker>=37.3.0 diff --git a/setup_env_file.py b/setup_env_file.py index e36e2fc6..98fcd3ea 100644 --- a/setup_env_file.py +++ b/setup_env_file.py @@ -11,19 +11,26 @@ import os from pathlib import Path -REQUIRED_KEYS = ["USER_PASS"] -DEFAULT_LOCAL_ENV_PATH = Path(os.getcwd()) / 'local.env' +REQUIRED_KEYS = ["BCSS_PASS", "ORACLE_USERNAME", "ORACLE_DB", "ORACLE_PASS"] +DEFAULT_LOCAL_ENV_PATH = Path(os.getcwd()) / "local.env" + def create_env_file(): """ Create a local.env file with the required keys. """ - with open(DEFAULT_LOCAL_ENV_PATH, 'w') as f: - f.write("# Use this file to populate secrets without committing them to the codebase (as this file is set in .gitignore).\n") - f.write("# To retrieve values as part of your tests, use os.getenv('VARIABLE_NAME').\n") - f.write("# Note: When running in a pipeline or workflow, you should pass these variables in at runtime.\n\n") + with open(DEFAULT_LOCAL_ENV_PATH, "w") as f: + f.write( + "# Use this file to populate secrets without committing them to the codebase (as this file is set in .gitignore).\n" + ) + f.write( + "# To retrieve values as part of your tests, use os.getenv('VARIABLE_NAME').\n" + ) + f.write( + "# Note: When running in a pipeline or workflow, you should pass these variables in at runtime.\n\n" + ) for key in REQUIRED_KEYS: - f.write(f'{key}=\n') + f.write(f"{key}=\n") if __name__ == "__main__": diff --git a/tests/bcss_tests.properties b/tests/bcss_tests.properties new file mode 100644 index 00000000..db7fa69e --- /dev/null +++ b/tests/bcss_tests.properties @@ -0,0 +1,44 @@ +# ---------------------------------- +# SCREENING CENTRES, CCGs, HUBS & GPs +# ---------------------------------- +screening_centre_code=BCS001 +eng_screening_centre_id=23162 +eng_hub_id=23159 +gp_practice_code=C81001 +ccg_code=Z1Z1Z +coventry_and_warwickshire_bcs_centre=23643 + +# ---------------------------------- +# SCREENING PRACTITIONERS +# ---------------------------------- +screening_practitioner_named_another_stubble=1982 + +# ---------------------------------- +# SUBJECT / PATIENT SEARCH TEST DATA +# ---------------------------------- +nhs_number=966 529 9271 +forename=Pentagram +surname=Absurd +subject_dob=11/01/1934 +episode_closed_date=22/09/2020 + +# ---------------------------------- +# CALL AND RECALL TEST DATA +# ---------------------------------- +daily_invitation_rate=28 +fobt_daily_invitation_rate=6 +weekly_invitation_rate=130 + +# ---------------------------------- +# Subject Notes Test DATA +# ---------------------------------- +additional_care_note_name=AN +additional_care_note_type_value=4112 +episode_note_name=EN +episode_note_type_value=4110 +subject_note_name=SN +subject_note_type_value=4111 +kit_note_name=KN +kit_note_type_value=308015 +note_status_active=4100 +note_status_obsolete=4101 diff --git a/tests/regression/call_and_recall/test_create_a_plan_regression.py b/tests/regression/call_and_recall/test_create_a_plan_regression.py new file mode 100644 index 00000000..f7191504 --- /dev/null +++ b/tests/regression/call_and_recall/test_create_a_plan_regression.py @@ -0,0 +1,109 @@ +import pytest +from playwright.sync_api import Page +from pages.base_page import BasePage +from pages.call_and_recall.call_and_recall_page import CallAndRecallPage +from pages.call_and_recall.invitations_monitoring_page import InvitationsMonitoringPage +from pages.call_and_recall.invitations_plans_page import InvitationsPlansPage +from pages.call_and_recall.create_a_plan_page import CreateAPlanPage +from utils.user_tools import UserTools + + +@pytest.fixture(scope="function", autouse=True) +def before_each(page: Page): + """ + Before every test is executed, this fixture logs in to BCSS as a test user and navigates to the call and recall page + """ + # Log in to BCSS + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + + # Go to call and recall page + BasePage(page).go_to_call_and_recall_page() + + +@pytest.mark.regression +@pytest.mark.call_and_recall +def test_create_a_plan_set_daily_rate(page: Page, general_properties: dict) -> None: + """ + Verifies that a user is able to click on the Set all button and enter a daily rate. + """ + # When I go to "Invitations Monitoring - Screening Centre" + CallAndRecallPage(page).go_to_planning_and_monitoring_page() + + # And I click the link text "BCS001" + InvitationsMonitoringPage(page).go_to_invitation_plan_page( + general_properties["screening_centre_code"] + ) + # And I click the "Create a Plan" button + InvitationsPlansPage(page).go_to_create_a_plan_page() + + # And I click the set all button + CreateAPlanPage(page).click_set_all_button() + + # And I enter "28" in the input box with id "dailyRate" + CreateAPlanPage(page).fill_daily_invitation_rate_field( + general_properties["daily_invitation_rate"] + ) + + # And I click the "Update" button + CreateAPlanPage(page).click_update_button() + + # Then the Weekly Invitation Rate for weeks 1 to 50 is set correctly + # based on a set all daily rate of 28 + CreateAPlanPage(page).verify_weekly_invitation_rate_for_weeks(1, 50, "140") + + +@pytest.mark.regression +@pytest.mark.call_and_recall +def test_create_a_plan_weekly_rate(page: Page, general_properties: dict) -> None: + """ + Verifies that a user can set a weekly invitation rate in Create a Plan. + """ + + # When I go to "Invitations Monitoring - Screening Centre" + CallAndRecallPage(page).go_to_planning_and_monitoring_page() + + # And I click the link text "BCS001" + InvitationsMonitoringPage(page).go_to_invitation_plan_page( + general_properties["screening_centre_code"] + ) + # And I click the "Create a Plan" button + InvitationsPlansPage(page).go_to_create_a_plan_page() + + # And I click the set all button + CreateAPlanPage(page).click_set_all_button() + + # And I enter "130" in the input box with id "weeklyRate" + CreateAPlanPage(page).fill_weekly_invitation_rate_field( + general_properties["weekly_invitation_rate"] + ) + + # And I click the "Update" button + CreateAPlanPage(page).click_update_button() + + # And the Weekly Invitation Rate for weeks 1 to 50 is set to the set all weekly rate of 130 + CreateAPlanPage(page).verify_weekly_invitation_rate_for_weeks(1, 50, "130") + + +@pytest.mark.regression +@pytest.mark.call_and_recall +def test_update_invitation_rate_weekly(page: Page, general_properties: dict) -> None: + """ + Verifies that a Hub Manager State Registered is able to update a weekly Invitation rate + and the Cumulative 'Invitations sent' and 'Resulting Position' values are updated. + """ + + # When I go to "Invitations Monitoring - Screening Centre" + CallAndRecallPage(page).go_to_planning_and_monitoring_page() + + # And I click the link text "BCS001" + InvitationsMonitoringPage(page).go_to_invitation_plan_page( + general_properties["screening_centre_code"] + ) + + # And I click the "Create a Plan" button + InvitationsPlansPage(page).go_to_create_a_plan_page() + + # When I increase the Weekly Invitation Rate for week 1 by 1 and tab out of the cell + # Then the Cumulative Invitations Sent is incremented by 1 for week 1 + # And the Cumulative Resulting Position is incremented by 1 for week 1 + CreateAPlanPage(page).increment_invitation_rate_and_verify_changes() diff --git a/tests/regression/call_and_recall/test_generate_fobt_invitation_regression.py b/tests/regression/call_and_recall/test_generate_fobt_invitation_regression.py new file mode 100644 index 00000000..4cb1791d --- /dev/null +++ b/tests/regression/call_and_recall/test_generate_fobt_invitation_regression.py @@ -0,0 +1,66 @@ +import pytest +from playwright.sync_api import Page +import logging +from pages.base_page import BasePage +from pages.call_and_recall.call_and_recall_page import CallAndRecallPage +from pages.call_and_recall.generate_invitations_page import GenerateInvitationsPage +from pages.communication_production.batch_list_page import BatchListPage +from pages.communication_production.communications_production_page import ( + CommunicationsProductionPage, +) +from pages.communication_production.manage_active_batch_page import ( + ManageActiveBatchPage, +) +from utils.user_tools import UserTools + + +@pytest.fixture(scope="function", autouse=True) +def before_each(page: Page): + """ + Before every test is executed, this fixture logs in to BCSS as a test user and navigates to the call and recall page + """ + # Log in to BCSS + UserTools.user_login(page, "Hub Manager at BCS01") + + # Go to call and recall page + BasePage(page).go_to_call_and_recall_page() + + +@pytest.mark.regression +@pytest.mark.call_and_recall +def test_run_fobt_invitations_and_process_s1_batch( + page: Page, general_properties: dict +): + """ + Run FOBT invitations, open the S1 batch, prepare, retrieve and confirm. + """ + # Navigate to generate invitations + CallAndRecallPage(page).go_to_generate_invitations_page() + + # When I generate invitations + logging.info("Generating invitations based on the invitation plan") + GenerateInvitationsPage(page).click_generate_invitations_button() + GenerateInvitationsPage(page).wait_for_invitation_generation_complete( + int(general_properties["fobt_daily_invitation_rate"]) + ) + logging.info("Invitations generated successfully") + + # And I view the active batch list + BasePage(page).click_main_menu_link() + BasePage(page).go_to_communications_production_page() + CommunicationsProductionPage(page).go_to_active_batch_list_page() + + # And I open the "Original" / "Open" / "S1" / "Pre-invitation (FIT)" batch + BatchListPage(page).open_letter_batch( + batch_type="Original", + status="Open", + level="S1", + description="Pre-invitation (FIT)", + ) + + # Then I retrieve and confirm the letters + ManageActiveBatchPage(page).click_prepare_button() + ManageActiveBatchPage(page).click_retrieve_button() + BasePage(page).safe_accept_dialog( + page.get_by_role("button", name="Confirm Printed") + ) # Click the confirm button and accept the confirmation dialog diff --git a/tests/regression/call_and_recall/test_non_invitation_days_regression.py b/tests/regression/call_and_recall/test_non_invitation_days_regression.py new file mode 100644 index 00000000..8b0db93f --- /dev/null +++ b/tests/regression/call_and_recall/test_non_invitation_days_regression.py @@ -0,0 +1,69 @@ +import pytest +from playwright.sync_api import Page +from pages.base_page import BasePage +from pages.call_and_recall.call_and_recall_page import CallAndRecallPage +from pages.call_and_recall.non_invitations_days_page import NonInvitationDaysPage +from utils.user_tools import UserTools +from utils.date_time_utils import DateTimeUtils + + +@pytest.fixture(scope="function", autouse=True) +def before_each(page: Page): + """ + Before every test is executed, this fixture logs in to BCSS as a test user and navigates to the call and recall page + """ + # Log in to BCSS + UserTools.user_login(page, "Hub Manager at BCS02") + + # Go to call and recall page + BasePage(page).go_to_call_and_recall_page() + + +@pytest.mark.regression +@pytest.mark.call_and_recall +def test_add_then_delete_non_invitation_day(page: Page) -> None: + """ + Verifies that a user can add and delete a non-invitation day. + """ + test_date = DateTimeUtils().generate_unique_weekday_date() + + # When I go to "Non-Invitation Days" + CallAndRecallPage(page).go_to_non_invitation_days_page() + + # And I enter a date in the input box with id "date" + # (The date entered should be a week day, otherwise a warning message will pop up) + NonInvitationDaysPage(page).enter_date(test_date) + + # And I enter "Add a non invitation day for automated test" in the input box with id "note" + NonInvitationDaysPage(page).enter_note( + "Add a non-invitation day for automated test" + ) + + # And I click the "Add Non-Invitation Day" button + NonInvitationDaysPage(page).click_add_non_invitation_day_button() + + # Then todays date is visible in the non-invitation days table + NonInvitationDaysPage(page).verify_created_on_date_is_visible() + + # When I click the delete button for the non-invitation day + # And I press OK on my confirmation prompt + BasePage(page).safe_accept_dialog(page.get_by_role("button", name="Delete")) + + # Then the non-invitation days has been successfully deleted + NonInvitationDaysPage(page).verify_created_on_date_is_not_visible() + + +@pytest.mark.regression +@pytest.mark.call_and_recall +def test_non_invitation_day_note_is_mandatory(page: Page) -> None: + """ + Verifies that a note is required when adding a non-invitation day. + """ + # And I go to "Non-Invitation Days" + CallAndRecallPage(page).go_to_non_invitation_days_page() + # When I enter "14/11/2030" in the input box with id "date" + NonInvitationDaysPage(page).enter_date("14/11/2030") + # And I click the "Add Non-Invitation Day" button + NonInvitationDaysPage(page).click_add_non_invitation_day_button() + # Then I get an alert message that "contains" "The Note field is mandatory" + BasePage(page).assert_dialog_text("The Note field is mandatory") diff --git a/tests/regression/notes/test_additional_care_notes.py b/tests/regression/notes/test_additional_care_notes.py new file mode 100644 index 00000000..83ac7097 --- /dev/null +++ b/tests/regression/notes/test_additional_care_notes.py @@ -0,0 +1,429 @@ +import logging +import pytest +from playwright.sync_api import Page, expect +from utils.user_tools import UserTools +from pages.base_page import BasePage +from pages.screening_subject_search.subject_screening_search_page import ( + SubjectScreeningPage, +) +from pages.screening_subject_search.subject_screening_summary_page import ( + SubjectScreeningSummaryPage, +) +from pages.screening_subject_search.subject_events_notes import ( + NotesOptions, + NotesStatusOptions, + SubjectEventsNotes, + AdditionalCareNoteTypeOptions, +) +from utils.oracle.oracle_specific_functions import ( + get_subjects_by_note_count, + get_subjects_with_multiple_notes, +) +from utils.screening_subject_page_searcher import search_subject_episode_by_nhs_number +from utils.subject_notes import ( + fetch_supporting_notes_from_db, + verify_note_content_matches_expected, + verify_note_content_ui_vs_db, + verify_note_removal_and_obsolete_transition, +) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_subject_does_not_have_an_additional_care_note( + page: Page, general_properties: dict +) -> None: + """ + Test to check if I can identify if a subject does not have a Additional Care note + """ + logging.info( + f"Starting test: Verify subject does not have a '{general_properties["additional_care_note_name"]}'." + ) + UserTools.user_login(page, "ScreeningAssistant at BCS02") + BasePage(page).go_to_screening_subject_search_page() + + # Search for the subject by NHS Number.") + subjects_df = get_subjects_by_note_count( + general_properties["additional_care_note_type_value"], + general_properties["note_status_active"], + 0, + ) + if subjects_df.empty: + pytest.fail( + f"No subjects found without '{general_properties["additional_care_note_name"]}'." + ) + + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + search_subject_episode_by_nhs_number(page, nhs_no) + # Verify no additional care notes are present + logging.info( + f"Verified that no '{general_properties['additional_care_note_name']}' link is visible for the subject." + ) + # logging.info("Verifying that no additional care notes are present for the subject.") + SubjectScreeningSummaryPage(page).verify_note_link_not_present( + general_properties["additional_care_note_name"] + ) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_add_an_additional_care_note_for_a_subject_without_a_note( + page: Page, general_properties: dict +) -> None: + """ + Test to add a note for a subject without an additional care note. + """ + logging.info( + "Starting test: Add a '{general_properties['additional_care_note_name']}' for a subject without a note." + ) + UserTools.user_login(page, "Team Leader at BCS01") + BasePage(page).go_to_screening_subject_search_page() + + # Get a subject with no notes of the specified type + subjects_df = get_subjects_by_note_count( + general_properties["additional_care_note_type_value"], + general_properties["note_status_active"], + 0, + ) + if subjects_df.empty: + pytest.fail( + f"No subjects found for note type {general_properties["additional_care_note_type_value"]}." + ) + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + search_subject_episode_by_nhs_number(page, nhs_no) + # search_subject_by_nhs(page, nhs_no) + + # Navigate to Subject Events & Notes + logging.info("Navigating to 'Subject Events & Notes' for the selected subject.") + SubjectScreeningSummaryPage(page).click_subjects_events_notes() + + # note type selection + logging.info( + f"Selecting note type based on value: '{general_properties["additional_care_note_type_value"]}'." + ) + SubjectEventsNotes(page).select_additional_care_note() + logging.info("Selecting Additional Care Note Type") + note_title = "Additional Care Need - Learning disability" + SubjectEventsNotes(page).select_additional_care_note_type( + AdditionalCareNoteTypeOptions.LEARNING_DISABILITY + ) + # Fill Notes + note_text = "adding additional care need notes" + logging.info(f"Filling in notes: '{note_text}'.") + SubjectEventsNotes(page).fill_notes(note_text) + # Dismiss dialog and update notes + logging.info("Dismissing dialog and clicking 'Update Notes'.") + SubjectEventsNotes(page).accept_dialog_and_update_notes() + + # Get supporting notes for the subject from DB + _, type_id, notes_df = fetch_supporting_notes_from_db( + subjects_df, nhs_no, general_properties["note_status_active"] + ) + + verify_note_content_matches_expected(notes_df, note_title, note_text, type_id) + + logging.info( + f"Verification successful: Additional care note added for the subject with NHS Number: {nhs_no}. " + f"Title and note matched the provided values. Title: '{note_title}', Note: '{note_text}'." + ) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_add_additional_care_note_for_subject_with_existing_note( + page: Page, general_properties: dict +) -> None: + """ + Test to add an additional care note for a subject who already has an existing note. + """ + # User login + logging.info( + "Starting test: Add an additional care note for a subject who already has additional care note." + ) + UserTools.user_login(page, "Team Leader at BCS01") + BasePage(page).go_to_screening_subject_search_page() + + # Get a subject with existing additional care notes + subjects_df = get_subjects_by_note_count( + general_properties["additional_care_note_type_value"], + general_properties["note_status_active"], + 1, + ) + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + search_subject_episode_by_nhs_number(page, nhs_no) + # Navigate to Subject Events & Notes + logging.info("Navigating to 'Subject Events & Notes' for the selected subject.") + SubjectScreeningSummaryPage(page).click_subjects_events_notes() + + # add an Additional Care Note if the subject already has one + logging.info("Selecting 'Additional Care Needs Note'.") + SubjectEventsNotes(page).select_additional_care_note() + + # Select Additional Care Note Type + note_title = "Additional Care Need - Learning disability" + logging.info(f"Selecting Additional Care Note Type: '{note_title}'.") + SubjectEventsNotes(page).select_additional_care_note_type( + AdditionalCareNoteTypeOptions.LEARNING_DISABILITY + ) + + # Fill Notes + note_text = "adding additional care need notes2" + logging.info(f"Filling in notes: '{note_text}'.") + SubjectEventsNotes(page).fill_notes(note_text) + + # Accept dialog and update notes + logging.info("Accept dialog and clicking 'Update Notes'.") + SubjectEventsNotes(page).accept_dialog_and_update_notes() + # Get supporting notes for the subject + _, type_id, notes_df = fetch_supporting_notes_from_db( + subjects_df, nhs_no, general_properties["note_status_active"] + ) + + verify_note_content_matches_expected(notes_df, note_title, note_text, type_id) + + logging.info( + f"Verification successful: Additional care note added for the subject with NHS Number: {nhs_no}. " + f"Title and note matched the provided values. Title: '{note_title}', Note: '{note_text}'." + ) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_identify_subject_with_additional_care_note( + page: Page, general_properties: dict +) -> None: + """ + Test to identify if a subject has an Additional Care note. + """ + logging.info("Starting test: Verify subject has an additional care note.") + UserTools.user_login(page, "ScreeningAssistant at BCS02") + BasePage(page).go_to_screening_subject_search_page() + + # Search for the subject by NHS Number.") + subjects_df = get_subjects_by_note_count( + general_properties["additional_care_note_type_value"], + general_properties["note_status_active"], + 1, + ) + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + search_subject_episode_by_nhs_number(page, nhs_no) + # Verify subject has additional care notes present + logging.info("Verified: Additional care notes are present for the subject.") + # logging.info("Verifying that additional care notes are present for the subject.") + SubjectScreeningSummaryPage(page).verify_note_link_present( + general_properties["additional_care_note_name"] + ) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_view_active_additional_care_note(page: Page, general_properties: dict) -> None: + """ + Test to verify if an active Additional Care note is visible for a subject. + """ + logging.info("Starting test: Verify subject has an additional care note.") + UserTools.user_login(page, "ScreeningAssistant at BCS02") + BasePage(page).go_to_screening_subject_search_page() + + # Search for the subject by NHS Number.") + subjects_df = get_subjects_by_note_count( + general_properties["additional_care_note_type_value"], 1 + ) + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + search_subject_episode_by_nhs_number(page, nhs_no) + # Verify subject has additional care notes present + logging.info("Verified: Additional care notes are present for the subject.") + # logging.info("Verifying that additional care notes are present for the subject.") + logging.info( + f"Verifying that the Additional Care Note is visible for the subject with NHS Number: {nhs_no}." + ) + SubjectScreeningSummaryPage(page).verify_note_link_present( + general_properties["additional_care_note_name"] + ) + logging.info( + f"Clicking on the 'Additional Care Note' link for the subject with NHS Number: {nhs_no}." + ) + + SubjectScreeningSummaryPage(page).click_subjects_events_notes() + SubjectEventsNotes(page).select_note_type(NotesOptions.ADDITIONAL_CARE_NOTE) + + # Get supporting notes for the subject + _, _, notes_df = fetch_supporting_notes_from_db( + subjects_df, nhs_no, general_properties["note_status_active"] + ) + + verify_note_content_ui_vs_db(page, notes_df) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_update_existing_additional_care_note( + page: Page, general_properties: dict +) -> None: + """ + Test to verify if an existing Additional Care note can be updated successfully. + """ + logging.info("Starting test: Verify subject has an additional care note.") + UserTools.user_login(page, "Team Leader at BCS01") + BasePage(page).go_to_screening_subject_search_page() + # Search for the subject by NHS Number.") + subjects_df = get_subjects_by_note_count( + general_properties["additional_care_note_type_value"], + general_properties["note_status_active"], + 1, + ) + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + search_subject_episode_by_nhs_number(page, nhs_no) + # Verify subject has additional care notes present + logging.info( + f"Verifying that the Additional Care Note is visible for the subject with NHS Number: {nhs_no}." + ) + SubjectScreeningSummaryPage(page).verify_note_link_present( + general_properties["additional_care_note_name"] + ) + SubjectScreeningSummaryPage(page).click_subjects_events_notes() + SubjectEventsNotes(page).select_note_type(NotesOptions.ADDITIONAL_CARE_NOTE) + BasePage(page).safe_accept_dialog_select_option( + SubjectEventsNotes(page).note_status, NotesStatusOptions.INVALID + ) + SubjectEventsNotes(page).select_additional_care_note() + SubjectEventsNotes(page).select_additional_care_note_type( + AdditionalCareNoteTypeOptions.HEARING_DISABILITY + ) + SubjectEventsNotes(page).fill_notes("updated additional care note") + SubjectEventsNotes(page).accept_dialog_and_add_replacement_note() + + # Get updated supporting notes for the subject + _, type_id, notes_df = fetch_supporting_notes_from_db( + subjects_df, nhs_no, general_properties["note_status_active"] + ) + # Verify title and note match the provided values + logging.info("Verifying that the updated title and note match the provided values.") + + # Define the expected title and note + note_title = "Additional Care Need - Hearing disability" + note_text = "updated additional care note" + # Log the expected title and note + logging.info(f"Expected title: '{note_title}'") + logging.info(f"Expected note: '{note_text}'") + + # Ensure the filtered DataFrame is not empty + if notes_df.empty: + pytest.fail( + f"No notes found for type_id: {general_properties["additional_care_note_type_value"]}. Expected at least one updated note." + ) + + # Verify title and note match the provided values + verify_note_content_matches_expected(notes_df, note_title, note_text, type_id) + + logging.info( + f"Verification successful: Additional care note added for the subject with NHS Number: {nhs_no}. " + f"Title and note matched the provided values. Title: '{note_title}', Note: '{note_text}'." + ) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_remove_existing_additional_care_note( + page: Page, general_properties: dict +) -> None: + """ + Test to verify if an existing Additional Care note can be removed for a subject with one Additional Care note. + """ + logging.info( + "Starting test: Verify if an existing Additional Care note can be removed for a subject with one Additional Care note" + ) + UserTools.user_login(page, "Team Leader at BCS01") + BasePage(page).go_to_screening_subject_search_page() + + # Search for the subject by NHS Number.") + subjects_df = get_subjects_by_note_count( + general_properties["additional_care_note_type_value"], + general_properties["note_status_active"], + 1, + ) + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + search_subject_episode_by_nhs_number(page, nhs_no) + # Verify subject has additional care notes present + logging.info( + f"Verifying that the Additional Care Note is visible for the subject with NHS Number: {nhs_no}." + ) + SubjectScreeningSummaryPage(page).verify_note_link_present( + general_properties["additional_care_note_name"] + ) + + SubjectScreeningSummaryPage(page).click_subjects_events_notes() + SubjectEventsNotes(page).select_note_type(NotesOptions.ADDITIONAL_CARE_NOTE) + logging.info( + "Selecting the 'Obsolete' option for the existing Additional Care Note." + ) + BasePage(page).safe_accept_dialog_select_option( + SubjectEventsNotes(page).note_status, NotesStatusOptions.OBSOLETE + ) + logging.info("Verifying that the subject does not have any Additional Care Notes.") + + _, _, notes_df = fetch_supporting_notes_from_db( + subjects_df, nhs_no, general_properties["note_status_active"] + ) + # Verify that the DataFrame is not empty + if not notes_df.empty: + pytest.fail( + f"Subject has Additional Care Notes. Expected none, but found: {notes_df}" + ) + + logging.info( + "Verification successful: Subject does not have any active Additional Care Notes." + ) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_remove_existing_additional_care_note_for_subject_with_multiple_notes( + page: Page, general_properties: dict +) -> None: + """ + Test to verify if an existing Additional Care note can be removed for a subject with multiple Additional Care notes. + """ + # User login + logging.info( + "Starting test: Remove an additional care note for a subject who already has multiple additional care note." + ) + UserTools.user_login(page, "Team Leader at BCS01") + BasePage(page).go_to_screening_subject_search_page() + + # Get a subject with multiple additional care notes + subjects_df = get_subjects_with_multiple_notes( + general_properties["additional_care_note_type_value"] + ) + if subjects_df.empty: + logging.info("No subjects found with multiple Additional Care Notes.") + pytest.fail("No subjects found with multiple Additional Care Notes.") + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + logging.info(f"Searching for subject with NHS Number: {nhs_no}") + search_subject_episode_by_nhs_number(page, nhs_no) + # Navigate to Subject Events & Notes + logging.info("Navigating to 'Subject Events & Notes' for the selected subject.") + SubjectScreeningSummaryPage(page).click_subjects_events_notes() + + SubjectEventsNotes(page).select_note_type(NotesOptions.ADDITIONAL_CARE_NOTE) + # Select the first Additional Care Note from the table for removal + logging.info("Selecting the first Additional Care Note from the table for removal.") + ui_data = SubjectEventsNotes(page).get_title_and_note_from_row(2) + logging.info( + "Removing one of the existing Additional Care Note by selecting 'Obsolete' option " + ) + BasePage(page).safe_accept_dialog_select_option( + SubjectEventsNotes(page).note_status, NotesStatusOptions.OBSOLETE + ) + logging.info( + "Verifying that the subject's removed additional care note is removed from DB as well " + ) + + verify_note_removal_and_obsolete_transition( + subjects_df, + ui_data, + general_properties, + note_type_key="additional_care_note_type_value", + status_active_key="note_status_active", + status_obsolete_key="note_status_obsolete", + ) diff --git a/tests/regression/notes/test_episode_notes.py b/tests/regression/notes/test_episode_notes.py new file mode 100644 index 00000000..a786e18d --- /dev/null +++ b/tests/regression/notes/test_episode_notes.py @@ -0,0 +1,252 @@ +import logging +import pytest +from playwright.sync_api import Page, expect +from utils.user_tools import UserTools +from pages.base_page import BasePage +from pages.screening_subject_search.subject_screening_search_page import ( + SubjectScreeningPage, +) +from pages.screening_subject_search.subject_screening_summary_page import ( + SubjectScreeningSummaryPage, +) +from pages.screening_subject_search.subject_events_notes import ( + NotesOptions, + NotesStatusOptions, + SubjectEventsNotes, +) +from utils.table_util import TableUtils +from utils.oracle.oracle_specific_functions import get_subjects_by_note_count +from utils.screening_subject_page_searcher import search_subject_episode_by_nhs_number +from utils.subject_notes import ( + fetch_supporting_notes_from_db, + verify_note_content_matches_expected, + verify_note_content_ui_vs_db, +) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_subject_does_not_have_a_episode_note( + page: Page, general_properties: dict +) -> None: + """ + Test to check if I can identify if a subject does not have a episode note + """ + logging.info( + f"Starting test: Verify subject does not have a '{general_properties["episode_note_name"]}'." + ) + UserTools.user_login(page, "ScreeningAssistant at BCS02") + BasePage(page).go_to_screening_subject_search_page() + + # Search for the subject by NHS Number.") + subjects_df = get_subjects_by_note_count( + general_properties["episode_note_type_value"], + general_properties["note_status_active"], + 0, + ) + if subjects_df.empty: + pytest.fail( + f"No subjects found without '{general_properties["episode_note_name"]}'." + ) + + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + search_subject_episode_by_nhs_number(page, nhs_no) + # Verify no episode notes are present + logging.info( + f"Verified that no '{general_properties['episode_note_name']}' link is visible for the subject." + ) + # logging.info("Verifying that no episode notes are present for the subject.") + SubjectScreeningSummaryPage(page).verify_note_link_not_present( + general_properties["episode_note_name"] + ) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_add_a_episode_note_for_a_subject_without_a_note( + page: Page, general_properties: dict +) -> None: + """ + Test to add a episode note for a subject without a episode note. + """ + # User login + logging.info( + "Starting test: Add a '{general_properties['episode_note_name']}' for a subject without a note." + ) + UserTools.user_login(page, "Team Leader at BCS01") + BasePage(page).go_to_screening_subject_search_page() + + # Get a subject with no notes of the specified type + subjects_df = get_subjects_by_note_count( + general_properties["episode_note_type_value"], + general_properties["note_status_active"], + 0, + ) + if subjects_df.empty: + pytest.fail( + f"No subjects found for note type {general_properties["episode_note_type_value"]}." + ) + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + search_subject_episode_by_nhs_number(page, nhs_no) + # Click on the Episode link + SubjectScreeningSummaryPage(page).click_list_episodes() + logging.info("Clicked on the list Episode link.") + + # Select the first link from the table + TableUtils(page, "#displayRS").click_first_link_in_column("View Events") + logging.info("Selected the first events link from the table.") + + note_title = "Episode Note - Follow-up required title" + # Set the note type for verification + note_text = "Episode Note - Follow-up required" + SubjectEventsNotes(page).fill_note_title(note_title) + # Set the note type for verification + logging.info(f"Filling in notes: '{note_text}'.") + SubjectEventsNotes(page).fill_notes(note_text) + # Dismiss dialog and update notes + logging.info("Accepting dialog and clicking 'Update Notes'.") + SubjectEventsNotes(page).accept_dialog_and_update_notes() + + # Get supporting notes for the subject from DB + _, type_id, notes_df = fetch_supporting_notes_from_db( + subjects_df, nhs_no, general_properties["note_status_active"] + ) + + # Verify title and note match the provided values + verify_note_content_matches_expected(notes_df, note_title, note_text, type_id) + + logging.info( + f"Verification successful: episode note added for the subject with NHS Number: {nhs_no}. " + f"Title and note matched the provided values. Title: '{note_title}', Note: '{note_text}'." + ) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_identify_subject_with_episode_note( + page: Page, general_properties: dict +) -> None: + """ + Test to identify if a subject has a episode note. + """ + logging.info("Starting test: Verify subject has a episode note.") + UserTools.user_login(page, "ScreeningAssistant at BCS02") + BasePage(page).go_to_screening_subject_search_page() + + # Search for the subject by NHS Number.") + subjects_df = get_subjects_by_note_count( + general_properties["episode_note_type_value"], + general_properties["note_status_active"], + 1, + ) + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + search_subject_episode_by_nhs_number(page, nhs_no) + # Verify subject has episode notes present + logging.info("Verified: episode notes are present for the subject.") + # logging.info("Verifying that episode notes are present for the subject.") + SubjectScreeningSummaryPage(page).verify_note_link_present( + general_properties["episode_note_name"] + ) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_view_active_episode_note(page: Page, general_properties: dict) -> None: + """ + Test to verify if an active episode note is visible for a subject. + """ + logging.info("Starting test: Verify subject has episode note.") + UserTools.user_login(page, "ScreeningAssistant at BCS02") + BasePage(page).go_to_screening_subject_search_page() + + # Search for the subject by NHS Number.") + subjects_df = get_subjects_by_note_count( + general_properties["episode_note_type_value"], 1 + ) + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + search_subject_episode_by_nhs_number(page, nhs_no) + # Verify subject has episode notes present + logging.info("Verified: episode notes are present for the subject.") + # logging.info("Verifying that episode notes are present for the subject.") + logging.info( + f"Verifying that the episode Note is visible for the subject with NHS Number: {nhs_no}." + ) + SubjectScreeningSummaryPage(page).verify_note_link_present( + general_properties["episode_note_name"] + ) + + SubjectScreeningSummaryPage(page).click_list_episodes() + # Select the first link from the table + TableUtils(page, "#displayRS").click_first_link_in_column("View Events") + logging.info("Selected the first events link from the table.") + SubjectEventsNotes(page).select_note_type(NotesOptions.EPISODE_NOTE) + + # Get supporting notes for the subject + _, _, notes_df = fetch_supporting_notes_from_db( + subjects_df, nhs_no, general_properties["note_status_active"] + ) + + verify_note_content_ui_vs_db(page, notes_df) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_update_existing_episode_note(page: Page, general_properties: dict) -> None: + """ + Test to verify if an existing episode note can be updated successfully. + """ + logging.info("Starting test: Verify subject has a episode note.") + UserTools.user_login(page, "Team Leader at BCS01") + BasePage(page).go_to_screening_subject_search_page() + # Search for the subject by NHS Number.") + subjects_df = get_subjects_by_note_count( + general_properties["episode_note_type_value"], + general_properties["note_status_active"], + 1, + ) + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + search_subject_episode_by_nhs_number(page, nhs_no) + # Verify subject has episode notes present + logging.info( + f"Verifying that the subject Note is visible for the subject with NHS Number: {nhs_no}." + ) + SubjectScreeningSummaryPage(page).verify_note_link_present( + general_properties["episode_note_name"] + ) + SubjectScreeningSummaryPage(page).click_list_episodes() + # Select the first link from the table + TableUtils(page, "#displayRS").click_first_link_in_column("View Events") + logging.info("Selected the first events link from the table.") + SubjectEventsNotes(page).select_note_type(NotesOptions.EPISODE_NOTE) + BasePage(page).safe_accept_dialog_select_option( + SubjectEventsNotes(page).episode_note_status, NotesStatusOptions.INVALID + ) + SubjectEventsNotes(page).fill_note_title("updated episode title") + SubjectEventsNotes(page).fill_notes("updated episode note") + SubjectEventsNotes(page).accept_dialog_and_add_replacement_note() + + # Get updated supporting notes for the subject + _, type_id, notes_df = fetch_supporting_notes_from_db( + subjects_df, nhs_no, general_properties["note_status_active"] + ) + + # Define the expected title and note + note_title = "updated episode title" + note_text = "updated episode note" + # Log the expected title and note + logging.info(f"Expected title: '{note_title}'") + logging.info(f"Expected note: '{note_text}'") + + # Ensure the filtered DataFrame is not empty + if notes_df.empty: + pytest.fail( + f"No notes found for type_id: {general_properties["episode_note_type_value"]}. Expected at least one updated note." + ) + + # Verify title and note match the provided values + verify_note_content_matches_expected(notes_df, note_title, note_text, type_id) + + logging.info( + f"Verification successful:Episode note added for the subject with NHS Number: {nhs_no}. " + f"Title and note matched the provided values. Title: '{note_title}', Note: '{note_text}'." + ) diff --git a/tests/regression/notes/test_kit_notes.py b/tests/regression/notes/test_kit_notes.py new file mode 100644 index 00000000..885fd959 --- /dev/null +++ b/tests/regression/notes/test_kit_notes.py @@ -0,0 +1,246 @@ +import logging +import pytest +from playwright.sync_api import Page, expect +from utils.user_tools import UserTools +from pages.base_page import BasePage +from pages.screening_subject_search.subject_screening_search_page import ( + SubjectScreeningPage, +) +from utils.screening_subject_page_searcher import search_subject_episode_by_nhs_number +from pages.screening_subject_search.subject_screening_summary_page import ( + SubjectScreeningSummaryPage, +) +from pages.screening_subject_search.subject_events_notes import ( + NotesOptions, + NotesStatusOptions, + SubjectEventsNotes, +) +from utils.oracle.oracle_specific_functions import get_subjects_by_note_count +from utils.subject_notes import ( + fetch_supporting_notes_from_db, + verify_note_content_matches_expected, + verify_note_content_ui_vs_db, +) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_subject_does_not_have_a_kit_note(page: Page, general_properties: dict) -> None: + """ + Test to check if I can identify if a subject does not have a kit note + """ + logging.info( + f"Starting test: Verify subject does not have a '{general_properties["kit_note_name"]}'." + ) + UserTools.user_login(page, "ScreeningAssistant at BCS02") + BasePage(page).go_to_screening_subject_search_page() + + # Search for the subject by NHS Number.") + subjects_df = get_subjects_by_note_count( + general_properties["kit_note_type_value"], + general_properties["note_status_active"], + 0, + ) + if subjects_df.empty: + pytest.fail( + f"No subjects found without '{general_properties["kit_note_name"]}'." + ) + + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + search_subject_episode_by_nhs_number(page, nhs_no) + # Verify no kit notes are present + logging.info( + f"Verified that no '{general_properties['kit_note_name']}' link is visible for the subject." + ) + # logging.info("Verifying that no kit notes are present for the subject.") + SubjectScreeningSummaryPage(page).verify_note_link_not_present( + general_properties["kit_note_name"] + ) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_add_a_kit_note_for_a_subject_without_a_note( + page: Page, general_properties: dict +) -> None: + """ + Test to add a note for a subject without a kit note. + """ + # User login + logging.info( + "Starting test: Add a '{general_properties['kit_note_name']}' for a subject without a note." + ) + UserTools.user_login(page, "Team Leader at BCS01") + BasePage(page).go_to_screening_subject_search_page() + + # Get a subject with no notes of the specified type + subjects_df = get_subjects_by_note_count( + general_properties["kit_note_type_value"], + general_properties["note_status_active"], + 0, + ) + if subjects_df.empty: + pytest.fail( + f"No subjects found for note type {general_properties["kit_note_type_value"]}." + ) + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + search_subject_episode_by_nhs_number(page, nhs_no) + + # Navigate to Subject Events & Notes + logging.info("Navigating to 'Subject Events & Notes' for the selected subject.") + SubjectScreeningSummaryPage(page).click_subjects_events_notes() + + # note type selection + logging.info( + f"Selecting note type based on value: '{general_properties["kit_note_type_value"]}'." + ) + SubjectEventsNotes(page).select_kit_note() + # Set the note status + note_title = "kit Note - General observation title" + logging.info(f"Filling in notes: '{note_title}'.") + SubjectEventsNotes(page).fill_note_title(note_title) + # Set the note type for verification + note_text = "kit Note - General observation" + logging.info(f"Filling in notes: '{note_text}'.") + SubjectEventsNotes(page).fill_notes(note_text) + # Accept dialog and update notes + logging.info("Accepting dialog and clicking 'Update Notes'.") + SubjectEventsNotes(page).accept_dialog_and_update_notes() + + # Get supporting notes for the subject from DB + _, type_id, notes_df = fetch_supporting_notes_from_db( + subjects_df, nhs_no, general_properties["note_status_active"] + ) + # Verify title and note match the provided values + verify_note_content_matches_expected(notes_df, note_title, note_text, type_id) + + logging.info( + f"Verification successful: Kit note added for the subject with NHS Number: {nhs_no}. " + f"Title and note matched the provided values. Title: '{note_title}', Note: '{note_text}'." + ) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_identify_subject_with_kit_note(page: Page, general_properties: dict) -> None: + """ + Test to identify if a subject has a kit note. + """ + logging.info("Starting test: Verify subject has a kit note.") + UserTools.user_login(page, "ScreeningAssistant at BCS02") + BasePage(page).go_to_screening_subject_search_page() + + # Search for the subject by NHS Number.") + subjects_df = get_subjects_by_note_count( + general_properties["kit_note_type_value"], + general_properties["note_status_active"], + 1, + ) + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + search_subject_episode_by_nhs_number(page, nhs_no) + # Verify subject has kit notes present + logging.info("Verified: kit notes are present for the subject.") + # logging.info("Verifying that kit notes are present for the subject.") + SubjectScreeningSummaryPage(page).verify_note_link_present( + general_properties["kit_note_name"] + ) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_view_active_kit_note(page: Page, general_properties: dict) -> None: + """ + Test to verify if an active kit note is visible for a subject. + """ + logging.info("Starting test: Verify subject has kit care note.") + UserTools.user_login(page, "ScreeningAssistant at BCS02") + BasePage(page).go_to_screening_subject_search_page() + + # Search for the subject by NHS Number.") + subjects_df = get_subjects_by_note_count( + general_properties["kit_note_type_value"], 1 + ) + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + search_subject_episode_by_nhs_number(page, nhs_no) + # Verify subject has kit notes present + logging.info("Verified: kit notes are present for the subject.") + # logging.info("Verifying that kit notes are present for the subject.") + logging.info( + f"Verifying that the kit Note is visible for the subject with NHS Number: {nhs_no}." + ) + SubjectScreeningSummaryPage(page).verify_note_link_present( + general_properties["kit_note_name"] + ) + + SubjectScreeningSummaryPage(page).click_subjects_events_notes() + SubjectEventsNotes(page).select_note_type(NotesOptions.KIT_NOTE) + + # Get supporting notes for the subject + _, _, notes_df = fetch_supporting_notes_from_db( + subjects_df, nhs_no, general_properties["note_status_active"] + ) + verify_note_content_ui_vs_db( + page, notes_df, title_prefix_to_strip="Subject Kit Note -" + ) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_update_existing_kit_note(page: Page, general_properties: dict) -> None: + """ + Test to verify if an existing kit note can be updated successfully. + """ + logging.info("Starting test: Verify subject has a kit note.") + UserTools.user_login(page, "Team Leader at BCS01") + BasePage(page).go_to_screening_subject_search_page() + # Search for the subject by NHS Number.") + subjects_df = get_subjects_by_note_count( + general_properties["kit_note_type_value"], + general_properties["note_status_active"], + 1, + ) + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + search_subject_episode_by_nhs_number(page, nhs_no) + # Verify subject has additional care notes present + logging.info( + f"Verifying that the kit Note is visible for the subject with NHS Number: {nhs_no}." + ) + SubjectScreeningSummaryPage(page).verify_note_link_present( + general_properties["kit_note_name"] + ) + SubjectScreeningSummaryPage(page).click_subjects_events_notes() + SubjectEventsNotes(page).select_note_type(NotesOptions.KIT_NOTE) + BasePage(page).safe_accept_dialog_select_option( + SubjectEventsNotes(page).note_status, NotesStatusOptions.INVALID + ) + SubjectEventsNotes(page).fill_note_title("updated kit title") + SubjectEventsNotes(page).fill_notes("updated kit note") + SubjectEventsNotes(page).accept_dialog_and_add_replacement_note() + + # Get updated supporting notes for the subject + _, type_id, notes_df = fetch_supporting_notes_from_db( + subjects_df, nhs_no, general_properties["note_status_active"] + ) + # Verify title and note match the provided values + logging.info("Verifying that the updated title and note match the provided values.") + + # Define the expected title and note + note_title = "updated kit title" + note_text = "updated kit note" + # Log the expected title and note + logging.info(f"Expected title: '{note_title}'") + logging.info(f"Expected note: '{note_text}'") + + # Ensure the filtered DataFrame is not empty + if notes_df.empty: + pytest.fail( + f"No notes found for type_id: {general_properties["kit_note_type_value"]}. Expected at least one updated note." + ) + + # Verify title and note match the provided values + verify_note_content_matches_expected(notes_df, note_title, note_text, type_id) + + logging.info( + f"Verification successful:Kit note added for the subject with NHS Number: {nhs_no}. " + f"Title and note matched the provided values. Title: '{note_title}', Note: '{note_text}'." + ) diff --git a/tests/regression/notes/test_subject_notes.py b/tests/regression/notes/test_subject_notes.py new file mode 100644 index 00000000..1b56917a --- /dev/null +++ b/tests/regression/notes/test_subject_notes.py @@ -0,0 +1,351 @@ +import logging +import pytest +from playwright.sync_api import Page, expect +from utils.user_tools import UserTools +from pages.base_page import BasePage +from pages.screening_subject_search.subject_screening_search_page import ( + SubjectScreeningPage, +) +from utils.screening_subject_page_searcher import search_subject_episode_by_nhs_number +from pages.screening_subject_search.subject_screening_summary_page import ( + SubjectScreeningSummaryPage, +) +from pages.screening_subject_search.subject_events_notes import ( + NotesOptions, + NotesStatusOptions, + SubjectEventsNotes, +) +from utils.oracle.oracle_specific_functions import ( + get_subjects_by_note_count, + get_subjects_with_multiple_notes, +) +from utils.subject_notes import ( + fetch_supporting_notes_from_db, + verify_note_content_matches_expected, + verify_note_content_ui_vs_db, + verify_note_removal_and_obsolete_transition, +) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_subject_does_not_have_a_subject_note( + page: Page, general_properties: dict +) -> None: + """ + Test to check if I can identify if a subject does not have a Subject note + """ + logging.info( + f"Starting test: Verify subject does not have a '{general_properties["subject_note_name"]}'." + ) + UserTools.user_login(page, "ScreeningAssistant at BCS02") + BasePage(page).go_to_screening_subject_search_page() + + # Search for the subject by NHS Number.") + subjects_df = get_subjects_by_note_count( + general_properties["subject_note_type_value"], + general_properties["note_status_active"], + 0, + ) + if subjects_df.empty: + pytest.fail( + f"No subjects found without '{general_properties["subject_note_name"]}'." + ) + + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + search_subject_episode_by_nhs_number(page, nhs_no) + # Verify no subject notes are present + logging.info( + f"Verified that no '{general_properties['subject_note_name']}' link is visible for the subject." + ) + # logging.info("Verifying that no subject notes are present for the subject.") + SubjectScreeningSummaryPage(page).verify_note_link_not_present( + general_properties["subject_note_name"] + ) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_add_a_subject_note_for_a_subject_without_a_note( + page: Page, general_properties: dict +) -> None: + """ + Test to add a note for a subject without a subject note. + """ + # User login + logging.info( + "Starting test: Add a '{general_properties['subject_note_name']}' for a subject without a note." + ) + UserTools.user_login(page, "Team Leader at BCS01") + BasePage(page).go_to_screening_subject_search_page() + + # Get a subject with no notes of the specified type + subjects_df = get_subjects_by_note_count( + general_properties["subject_note_type_value"], + general_properties["note_status_active"], + 0, + ) + if subjects_df.empty: + pytest.fail( + f"No subjects found for note type {general_properties["subject_note_type_value"]}." + ) + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + logging.info(f"Searching for subject with NHS Number: {nhs_no}") + search_subject_episode_by_nhs_number(page, nhs_no) + + # Navigate to Subject Events & Notes + logging.info("Navigating to 'Subject Events & Notes' for the selected subject.") + SubjectScreeningSummaryPage(page).click_subjects_events_notes() + + # note type selection + logging.info( + f"Selecting note type based on value: '{general_properties["subject_note_type_value"]}'." + ) + SubjectEventsNotes(page).select_subject_note() + # Set the note status + note_title = "Subject Note - General observation title" + logging.info(f"Filling in notes: '{note_title}'.") + SubjectEventsNotes(page).fill_note_title(note_title) + # Set the note type for verification + note_text = "Subject Note - General observation" + logging.info(f"Filling in notes: '{note_text}'.") + SubjectEventsNotes(page).fill_notes(note_text) + # Dismiss dialog and update notes + logging.info("Dismissing dialog and clicking 'Update Notes'.") + SubjectEventsNotes(page).accept_dialog_and_update_notes() + + # Get supporting notes for the subject from DB + _, type_id, notes_df = fetch_supporting_notes_from_db( + subjects_df, nhs_no, general_properties["note_status_active"] + ) + # Verify title and note match the provided values + verify_note_content_matches_expected(notes_df, note_title, note_text, type_id) + logging.info( + f"Verification successful: subject note added for the subject with NHS Number: {nhs_no}. " + f"Title and note matched the provided values. Title: '{note_title}', Note: '{note_text}'." + ) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_identify_subject_with_subject_note( + page: Page, general_properties: dict +) -> None: + """ + Test to identify if a subject has an subject note. + """ + logging.info("Starting test: Verify subject has a subject note.") + UserTools.user_login(page, "ScreeningAssistant at BCS02") + BasePage(page).go_to_screening_subject_search_page() + + # Search for the subject by NHS Number.") + subjects_df = get_subjects_by_note_count( + general_properties["subject_note_type_value"], + general_properties["note_status_active"], + 1, + ) + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + search_subject_episode_by_nhs_number(page, nhs_no) + # Verify subject has subject notes present + logging.info("Verified: Subject notes are present for the subject.") + # logging.info("Verifying that additional care notes are present for the subject.") + SubjectScreeningSummaryPage(page).verify_note_link_present( + general_properties["subject_note_name"] + ) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_view_active_subject_note(page: Page, general_properties: dict) -> None: + """ + Test to verify if an active subject note is visible for a subject. + """ + logging.info("Starting test: Verify subject has subject note.") + UserTools.user_login(page, "ScreeningAssistant at BCS02") + BasePage(page).go_to_screening_subject_search_page() + + # Search for the subject by NHS Number.") + subjects_df = get_subjects_by_note_count( + general_properties["subject_note_type_value"], 1 + ) + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + search_subject_episode_by_nhs_number(page, nhs_no) + # Verify subject has subject notes present + logging.info("Verified: subject notes are present for the subject.") + # logging.info("Verifying that subject notes is present for the subject.") + logging.info( + f"Verifying that the subject Note is visible for the subject with NHS Number: {nhs_no}." + ) + SubjectScreeningSummaryPage(page).verify_note_link_present( + general_properties["subject_note_name"] + ) + + SubjectScreeningSummaryPage(page).click_subjects_events_notes() + SubjectEventsNotes(page).select_note_type(NotesOptions.SUBJECT_NOTE) + + # Get supporting notes for the subject + _, _, notes_df = fetch_supporting_notes_from_db( + subjects_df, nhs_no, general_properties["note_status_active"] + ) + verify_note_content_ui_vs_db(page, notes_df) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_update_existing_subject_note(page: Page, general_properties: dict) -> None: + """ + Test to verify if an existing subject note can be updated successfully. + """ + logging.info("Starting test: Verify subject has a subject note.") + UserTools.user_login(page, "Team Leader at BCS01") + BasePage(page).go_to_screening_subject_search_page() + # Search for the subject by NHS Number.") + subjects_df = get_subjects_by_note_count( + general_properties["subject_note_type_value"], + general_properties["note_status_active"], + 1, + ) + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + search_subject_episode_by_nhs_number(page, nhs_no) + # Verify subject has subject notes present + logging.info( + f"Verifying that the subject Note is visible for the subject with NHS Number: {nhs_no}." + ) + SubjectScreeningSummaryPage(page).verify_note_link_present( + general_properties["subject_note_name"] + ) + SubjectScreeningSummaryPage(page).click_subjects_events_notes() + SubjectEventsNotes(page).select_note_type(NotesOptions.SUBJECT_NOTE) + BasePage(page).safe_accept_dialog_select_option( + SubjectEventsNotes(page).note_status, NotesStatusOptions.INVALID + ) + SubjectEventsNotes(page).fill_note_title("updated subject title") + SubjectEventsNotes(page).fill_notes("updated subject note") + SubjectEventsNotes(page).accept_dialog_and_add_replacement_note() + + # Get updated supporting notes for the subject + _, type_id, notes_df = fetch_supporting_notes_from_db( + subjects_df, nhs_no, general_properties["note_status_active"] + ) + # Verify title and note match the provided values + logging.info("Verifying that the updated title and note match the provided values.") + + # Define the expected title and note + note_title = "updated subject title" + note_text = "updated subject note" + # Log the expected title and note + logging.info(f"Expected title: '{note_title}'") + logging.info(f"Expected note: '{note_text}'") + + # Ensure the filtered DataFrame is not empty + if notes_df.empty: + pytest.fail( + f"No notes found for type_id: {general_properties["subject_note_type_value"]}. Expected at least one updated note." + ) + + # Verify title and note match the provided values + verify_note_content_matches_expected(notes_df, note_title, note_text, type_id) + + logging.info( + f"Verification successful:Subject note added for the subject with NHS Number: {nhs_no}. " + f"Title and note matched the provided values. Title: '{note_title}', Note: '{note_text}'." + ) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_remove_existing_subject_note(page: Page, general_properties: dict) -> None: + """ + Test to verify if an existing Subject note can be removed for a subject with one Subject note. + """ + logging.info( + "Starting test: Verify if an existing Subject note can be removed for a subject with one Subject note" + ) + UserTools.user_login(page, "Team Leader at BCS01") + BasePage(page).go_to_screening_subject_search_page() + + # Search for the subject by NHS Number.") + subjects_df = get_subjects_by_note_count( + general_properties["subject_note_type_value"], + general_properties["note_status_active"], + 1, + ) + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + search_subject_episode_by_nhs_number(page, nhs_no) + # Verify subject has subject notes present + logging.info( + f"Verifying that the Subject Note is visible for the subject with NHS Number: {nhs_no}." + ) + SubjectScreeningSummaryPage(page).verify_note_link_present( + general_properties["subject_note_name"] + ) + SubjectScreeningSummaryPage(page).click_subjects_events_notes() + SubjectEventsNotes(page).select_note_type(NotesOptions.SUBJECT_NOTE) + logging.info("Selecting the 'Obsolete' option for the existing Subject Note.") + BasePage(page).safe_accept_dialog_select_option( + SubjectEventsNotes(page).note_status, NotesStatusOptions.OBSOLETE + ) + logging.info("Verifying that the subject does not have any Subject Notes.") + + _, _, notes_df = fetch_supporting_notes_from_db( + subjects_df, nhs_no, general_properties["note_status_active"] + ) + # Verify that the DataFrame is not empty + if not notes_df.empty: + pytest.fail(f"Subject has Subject Notes. Expected none, but found: {notes_df}") + + logging.info( + "Verification successful: Subject does not have any active Subject Notes." + ) + + +@pytest.mark.regression +@pytest.mark.note_tests +def test_remove_existing_subject_note_for_subject_with_multiple_notes( + page: Page, general_properties: dict +) -> None: + """ + Test to verify if an existing subject note can be removed for a subject with multiple Subject notes. + """ + # User login + logging.info( + "Starting test: Remove a subject note for a subject who already has multiple Subject note." + ) + UserTools.user_login(page, "Team Leader at BCS01") + BasePage(page).go_to_screening_subject_search_page() + + # Get a subject with multiple subject notes + subjects_df = get_subjects_with_multiple_notes( + general_properties["subject_note_type_value"] + ) + if subjects_df.empty: + logging.info("No subjects found with multiple Subject Notes.") + pytest.fail("No subjects found with multiple Subject Notes.") + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + logging.info(f"Searching for subject with NHS Number: {nhs_no}") + search_subject_episode_by_nhs_number(page, nhs_no) + # Navigate to Subject Events & Notes + logging.info("Navigating to 'Subject Events & Notes' for the selected subject.") + SubjectScreeningSummaryPage(page).click_subjects_events_notes() + + SubjectEventsNotes(page).select_note_type(NotesOptions.SUBJECT_NOTE) + # Select the first Subject Note from the table for removal + logging.info("Selecting the first Subject Note from the table for removal.") + ui_data = SubjectEventsNotes(page).get_title_and_note_from_row(2) + logging.info( + "Removing one of the existing Subject Note by selecting 'Obsolete' option " + ) + BasePage(page).safe_accept_dialog_select_option( + SubjectEventsNotes(page).note_status, NotesStatusOptions.OBSOLETE + ) + logging.info( + "Verifying that the subject's removed subject note is removed from DB as well " + ) + verify_note_removal_and_obsolete_transition( + subjects_df, + ui_data, + general_properties, + note_type_key="subject_note_type_value", + status_active_key="note_status_active", + status_obsolete_key="note_status_obsolete", + ) diff --git a/tests/regression/organisation_regression_tests_user/test_change_organisation_regression_user.py b/tests/regression/organisation_regression_tests_user/test_change_organisation_regression_user.py new file mode 100644 index 00000000..99e34704 --- /dev/null +++ b/tests/regression/organisation_regression_tests_user/test_change_organisation_regression_user.py @@ -0,0 +1,41 @@ +import pytest +import logging +from utils.user_tools import UserTools +from playwright.sync_api import Page +from pages.organisations.organisations_page import OrganisationSwitchPage + + +@pytest.mark.regression +def test_user_can_switch_between_organisations(page: Page) -> None: + """ + Feature: Change Organisation + Scenario: Check that an English user with multiple organisations is able to switch between them + Given I log in to BCSS "England" as user role "MultiOrgUser" + When I change organisation + Then I will be logged in as the alternative organisation. + """ + + # Log in as a user with multiple organisations + UserTools.user_login(page, "Specialist Screening Practitioner at BCS009 & BCS001") + org_switch_page = OrganisationSwitchPage(page) + + # Get the list of available organisation IDs + org_ids = org_switch_page.get_available_organisation_ids() + + # Select available organisations in turn and verify the switch + for org_id in org_ids: + # Select the organisation + org_switch_page.select_organisation_by_id(org_id) + + # Click continue + org_switch_page.click_continue() + + # Assert logged-in org matches expected + login_text = org_switch_page.get_logged_in_text() + logging.info(f"The user's current organisation is: {login_text}") + assert ( + org_id in login_text + ), f"Expected to be logged in as '{org_id}', but got: {login_text}" + + # Return to selection screen + org_switch_page.click_select_org_link() diff --git a/tests/regression/subject/demographic/test_temporary_address.py b/tests/regression/subject/demographic/test_temporary_address.py new file mode 100644 index 00000000..ef70e037 --- /dev/null +++ b/tests/regression/subject/demographic/test_temporary_address.py @@ -0,0 +1,380 @@ +import pytest +from playwright.sync_api import Page +from utils.user_tools import UserTools +from classes.user import User +from classes.subject import Subject +from pages.base_page import BasePage +from pages.screening_subject_search.subject_demographic_page import ( + SubjectDemographicPage, +) +from pages.logout.log_out_page import LogoutPage +from utils.screening_subject_page_searcher import ( + search_subject_demographics_by_nhs_number, +) +from utils.oracle.oracle import OracleDB +from utils.oracle.oracle_specific_functions import ( + check_if_subject_has_temporary_address, +) +from utils.oracle.subject_selection_query_builder import SubjectSelectionQueryBuilder +import logging +from faker import Faker +from datetime import datetime, timedelta + + +@pytest.fixture(scope="function", autouse=True) +def before_each(page: Page) -> str: + """ + Before every test is executed, this fixture: + - Logs into BCSS as a Screening Centre Manager at BCS001 + - Navigates to the screening subject search page + """ + nhs_no = obtain_test_data_nhs_no() + logging.info(f"Selected NHS Number: {nhs_no}") + UserTools.user_login(page, "Screening Centre Manager at BCS001") + BasePage(page).go_to_screening_subject_search_page() + search_subject_demographics_by_nhs_number(page, nhs_no) + return nhs_no + + +@pytest.mark.wip +@pytest.mark.regression +@pytest.mark.subject_tests +def test_not_amending_temporary_address(page: Page, before_each) -> None: + """ + Scenario: If not amending a temporary address, no need to validate it + + This test is checking that if a temporary address is not being amended, + and the subject's postcode is updated. + That the subject does not have a temporary address added to them. + """ + nhs_no = before_each + fake = Faker("en_GB") + random_postcode = fake.postcode() + SubjectDemographicPage(page).fill_postcode_input(random_postcode) + SubjectDemographicPage(page).postcode_field.press("Tab") + SubjectDemographicPage(page).click_update_subject_data_button() + + check_subject_has_temporary_address(nhs_no, temporary_address=False) + LogoutPage(page).log_out() + + +@pytest.mark.wip +@pytest.mark.regression +@pytest.mark.subject_tests +def test_add_temporary_address_then_delete(page: Page, before_each) -> None: + """ + Add a temporary address, then delete it. + + This test is checking that a temporary address can be added to a subject, + and then deleted successfully, ensuring the temporary address icon behaves as expected. + """ + nhs_no = before_each + + temp_address = { + "valid_from": datetime.today(), + "valid_to": datetime.today() + timedelta(days=31), + "address_line_1": "Temporary Address Line 1", + "address_line_2": "Temporary Address Line 2", + "address_line_3": "Temporary Address Line 3", + "address_line_4": "Temporary Address Line 4", + "address_line_5": "Temporary Address Line 5", + "postcode": "AB12 3CD", + } + SubjectDemographicPage(page).update_temporary_address(temp_address) + check_subject_has_temporary_address(nhs_no, temporary_address=True) + + temp_address = { + "valid_from": None, + "valid_to": None, + "address_line_1": "", + "address_line_2": "", + "address_line_3": "", + "address_line_4": "", + "address_line_5": "", + "postcode": "", + } + SubjectDemographicPage(page).update_temporary_address(temp_address) + + check_subject_has_temporary_address(nhs_no, temporary_address=False) + LogoutPage(page).log_out() + + +@pytest.mark.wip +@pytest.mark.regression +@pytest.mark.subject_tests +def test_validation_regarding_dates(page: Page) -> None: + """ + Checks the validation regarding the temporary address date fields works as expected. + + This test is checking that the validation for the temporary address date fields + works correctly when the user tries to enter a temporary address with invalid dates. + It ensures that the user is prompted with appropriate error messages when the dates are not valid. + """ + + temp_address = { + "valid_from": None, + "valid_to": None, + "address_line_1": "Line 1", + "address_line_2": "Line 2", + "address_line_3": "Line 3", + "address_line_4": "Line 4", + "address_line_5": "Line 5", + "postcode": "EX2 5SE", + } + subject_page = SubjectDemographicPage(page) + + subject_page.assert_dialog_text( + "If entering a temporary address, please specify the From-date" + ) + subject_page.update_temporary_address(temp_address) + dialog_error = getattr(subject_page, "_dialog_assertion_error", None) + if dialog_error is not None: + raise dialog_error + logging.info("Temporary address validation for 'From-date' passed.") + + temp_address["valid_from"] = datetime(1900, 1, 1) + subject_page.assert_dialog_text( + "If entering a temporary address, please specify the To-date" + ) + subject_page.update_temporary_address(temp_address) + dialog_error = getattr(subject_page, "_dialog_assertion_error", None) + if dialog_error is not None: + raise dialog_error + logging.info("Temporary address validation for 'To-date' passed.") + + temp_address["valid_to"] = datetime(1900, 1, 2) + subject_page.assert_dialog_text( + "The From-date of the Subject's temporary address must not be before the Date of Birth" + ) + subject_page.update_temporary_address(temp_address) + dialog_error = getattr(subject_page, "_dialog_assertion_error", None) + if dialog_error is not None: + raise dialog_error + logging.info("Temporary address validation for 'From-date before DOB' passed.") + + # Reset to valid state before next test + temp_address["valid_from"] = datetime.today() + temp_address["valid_to"] = datetime.today() + timedelta(days=1) + subject_page.update_temporary_address(temp_address) + + temp_address["valid_from"] = datetime.today() + timedelta(days=1) + temp_address["valid_to"] = datetime.today() + subject_page.assert_dialog_text( + "The From-date of the Subject's temporary address must not be after the To-date" + ) + subject_page.update_temporary_address(temp_address) + dialog_error = getattr(subject_page, "_dialog_assertion_error", None) + if dialog_error is not None: + raise dialog_error + logging.info("Temporary address validation for 'From-date after To-date' passed.") + + LogoutPage(page).log_out() + + +@pytest.mark.wip +@pytest.mark.regression +@pytest.mark.subject_tests +def test_ammending_temporary_address(page: Page, before_each) -> None: + """ + Scenario: If amending a temporary address, it should be validated. + + This test checks that if a temporary address is being amended, + and the subject's postcode is updated. + That the subject has a temporary address added to them. + """ + nhs_no = before_each + + temp_address = { + "valid_from": datetime(2000, 1, 1), + "valid_to": datetime(2000, 1, 2), + "address_line_1": "Line 1", + "address_line_2": "Line 2", + "address_line_3": "Line 3", + "address_line_4": "Line 4", + "address_line_5": "Line 5", + "postcode": "EX2 5SE", + } + SubjectDemographicPage(page).update_temporary_address(temp_address) + check_subject_has_temporary_address(nhs_no, temporary_address=True) + + temp_address = { + "valid_from": datetime(3000, 1, 1), + "valid_to": datetime(3000, 1, 2), + "address_line_1": "Line 1.1", + "address_line_2": "Line 2.1", + "address_line_3": "Line 3.1", + "address_line_4": "Line 4.1", + "address_line_5": "Line 5.1", + "postcode": "EX2 5SE", + } + SubjectDemographicPage(page).update_temporary_address(temp_address) + check_subject_has_temporary_address(nhs_no, temporary_address=True) + + temp_address = { + "valid_from": None, + "valid_to": None, + "address_line_1": "", + "address_line_2": "", + "address_line_3": "", + "address_line_4": "", + "address_line_5": "", + "postcode": "", + } + SubjectDemographicPage(page).update_temporary_address(temp_address) + check_subject_has_temporary_address(nhs_no, temporary_address=False) + + LogoutPage(page).log_out() + + +@pytest.mark.wip +@pytest.mark.regression +@pytest.mark.subject_tests +def test_validating_minimum_information(page: Page, before_each) -> None: + """ + Scenario: Validation regarding minimum information + This test checks that the validation for the temporary address fields + works correctly when the user tries to enter a temporary address with minimum information. + It ensures that the user is prompted with appropriate error messages when the minimum information is not provided + """ + nhs_no = before_each + + temp_address = { + "valid_from": datetime.today(), + "valid_to": datetime.today() + timedelta(days=1), + "address_line_1": "", + "address_line_2": "", + "address_line_3": "", + "address_line_4": "", + "address_line_5": "", + "postcode": "", + } + + subject_page = SubjectDemographicPage(page) + subject_page.assert_dialog_text( + "If entering a temporary address, please specify at least the first two lines and the postcode" + ) + subject_page.update_temporary_address(temp_address) + dialog_error = getattr(subject_page, "_dialog_assertion_error", None) + if dialog_error is not None: + raise dialog_error + logging.info("Temporary address validation for minimum information passed.") + + temp_address["address_line_1"] = "min1" + temp_address["address_line_2"] = "min2" + subject_page.assert_dialog_text( + "If entering a temporary address, please specify at least the first two lines and the postcode" + ) + subject_page.update_temporary_address(temp_address) + dialog_error = getattr(subject_page, "_dialog_assertion_error", None) + if dialog_error is not None: + raise dialog_error + logging.info("Temporary address validation for minimum information passed.") + + temp_address["address_line_1"] = "min1" + temp_address["address_line_2"] = "" + temp_address["postcode"] = "pc" + subject_page.assert_dialog_text( + "If entering a temporary address, please specify at least the first two lines and the postcode" + ) + subject_page.update_temporary_address(temp_address) + dialog_error = getattr(subject_page, "_dialog_assertion_error", None) + if dialog_error is not None: + raise dialog_error + logging.info("Temporary address validation for minimum information passed.") + + temp_address["address_line_1"] = "" + temp_address["address_line_2"] = "min2" + subject_page.assert_dialog_text( + "If entering a temporary address, please specify at least the first two lines and the postcode" + ) + subject_page.update_temporary_address(temp_address) + dialog_error = getattr(subject_page, "_dialog_assertion_error", None) + if dialog_error is not None: + raise dialog_error + logging.info("Temporary address validation for minimum information passed.") + + temp_address["address_line_1"] = "min1" + temp_address["address_line_2"] = "min2" + temp_address["postcode"] = "pc" + subject_page.update_temporary_address(temp_address) + check_subject_has_temporary_address(nhs_no, temporary_address=True) + + temp_address = { + "valid_from": None, + "valid_to": None, + "address_line_1": "", + "address_line_2": "", + "address_line_3": "", + "address_line_4": "", + "address_line_5": "", + "postcode": "", + } + subject_page = SubjectDemographicPage(page) + subject_page.update_temporary_address(temp_address) + check_subject_has_temporary_address(nhs_no, temporary_address=False) + + +def check_subject_has_temporary_address(nhs_no: str, temporary_address: bool) -> None: + """ + Checks if the subject has a temporary address in the database. + Args: + nhs_no (str): The NHS number of the subject. + temporary_address (bool): True if checking for a temporary address, False otherwise. + Raises: + AssertionError: If the expected address status does not match the actual status in the database. + This function queries the database to determine if the subject has a temporary address + and asserts the result against the expected value. + The result is then compared to the expected status, and an assertion is raised if they do not match. + """ + + df = check_if_subject_has_temporary_address(nhs_no) + + if temporary_address: + logging.info( + "Checking if the subject has an active temporary address in the database." + ) + assert ( + df.iloc[0]["address_status"] == "Subject has a temporary address" + ), "Temporary address not found in the database." + logging.info( + "Assertion passed: Active temporary address found in the database." + ) + else: + logging.info( + "Checking if the subject does not have an active temporary address in the database." + ) + assert ( + df.iloc[0]["address_status"] == "Subject doesn't have a temporary address" + ), "Temporary address found in the database when it shouldn't be." + logging.info( + "Assertion passed: No active temporary address found in the database." + ) + + +def obtain_test_data_nhs_no() -> str: + """ + Obtain a test subject's NHS number that matches the following criteria: + | Subject age | <= 80 | + | Subject has temporary address | No | + + This is obtained using the Subject Selection Query Builder. + + Returns: + str: The NHS number of the subject that matches the criteria. + """ + criteria = { + "subject age": "<= 80", + "subject has temporary address": "no", + } + user = User() + subject = Subject() + + builder = SubjectSelectionQueryBuilder() + + query, bind_vars = builder.build_subject_selection_query( + criteria=criteria, user=user, subject=subject, subjects_to_retrieve=1 + ) + + df = OracleDB().execute_query(query, bind_vars) + nhs_no = df.iloc[0]["subject_nhs_number"] + return nhs_no diff --git a/tests/regression/subject_search_criteria/test_screening_subject_search_criteria_page_regression.py b/tests/regression/subject_search_criteria/test_screening_subject_search_criteria_page_regression.py new file mode 100644 index 00000000..af8a50aa --- /dev/null +++ b/tests/regression/subject_search_criteria/test_screening_subject_search_criteria_page_regression.py @@ -0,0 +1,33 @@ +import pytest +from playwright.sync_api import Page +from utils.user_tools import UserTools +from utils.table_util import TableUtils +from pages.base_page import BasePage +from utils.screening_subject_page_searcher import search_subject_by_surname +from pages.screening_subject_search.subject_screening_search_page import ( + SubjectScreeningPage, +) + +@pytest.mark.regression +def test_user_can_search_for_subject_and_results_are_returned(page: Page): + """ + Verify that User can log in to BCSS "England" as user role "Hub Manager - State Registered" + Navigate it to the Subject Search Criteria Page & added value "S*" to the "Surname" search field + Clicking on the search button on the subject search criteria page + Then Verifies that search results contain Surnames beginning with S + """ + + # Log in as Hub Manager - State Registered (England) + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + + # Navigate to the Subject Search Criteria Page + BasePage(page).go_to_screening_subject_search_page() + + # Add value "S*" to the "Surname" search field + # click search button on the subject search criteria page + search_subject_by_surname(page, "S*") + + # Assert that some results are returned with the surname starting with "S*" + TableUtils( + page, SubjectScreeningPage(page).results_table_locator + ).assert_surname_in_table("S*") diff --git a/tests/smokescreen/bcss_smokescreen_tests.properties b/tests/smokescreen/bcss_smokescreen_tests.properties new file mode 100644 index 00000000..17359a15 --- /dev/null +++ b/tests/smokescreen/bcss_smokescreen_tests.properties @@ -0,0 +1,93 @@ +# ---------------------------------- +# compartment 1 +# ---------------------------------- + c1_daily_invitation_rate=10 + c1_screening_centre_code=BCS001 + +# ---------------------------------- +# compartment 2 +# ---------------------------------- + c2_normal_kits_to_log=9 + c2_total_fit_kits_to_retieve=10 + c2_fit_kit_logging_test_org_id=23159 + c2_fit_kit_tk_type_id=2 +# c2_eng_gfobt_kit_logging_test_org_id=23159 + +# ---------------------------------- +# compartment 3 +# ---------------------------------- + c3_fit_kit_results_test_org_id=23159 + c3_fit_kit_normal_result=75 + c3_fit_kit_abnormal_result=150 + c3_fit_kit_analyser_code=UU2_tdH3 + c3_total_fit_kits_to_retrieve=9 + c3_fit_kit_authorised_user=AUTO1 + +# ---------------------------------- +# compartment 4 +# ---------------------------------- + c4_eng_weeks_to_make_available = 6 + c4_eng_centre_name=BCS001 - Wolverhampton Bowel Cancer Screening Centre + c4_eng_site_name1=THE ROYAL HOSPITAL (WOLVERHAMPTON) + c4_eng_site_name2=The Royal Hospital (Wolverhampton) + c4_eng_practitioner_name=Astonish, Ethanol + +# ---------------------------------- +# compartment 5 +# ---------------------------------- + c5_eng_appointment_type=Colonoscopy Assessment + c5_eng_screening_centre=BCS001 - Wolverhampton Bowel Cancer Screening Centre + c5_eng_site=(all) + +# ---------------------------------- +# compartment 6 +# ---------------------------------- + c6_eng_org_id=23159 +# c6_eng_site_id=35317 +# c6_eng_practitioner_id=243 +# c6_eng_testing_clinician_id=805 +# c6_eng_aspirant_endoscopist_id=1731 + +# ---------------------------------- +# NUMBER OF KITS / SUBJECTS TO PROCESS IN EACH COMPARTMENT +# ---------------------------------- +# NOTE: England gFOBT has been turned off (all values set to 0), properties have been left just in case for now + +# ---------------------------------- +# compartment 1 +# ---------------------------------- +# c1_eng_number_of_batches_to_create=1 + +# ---------------------------------- +# compartment 2 +# ---------------------------------- +# c2_eng_number_of_fit_kits_to_log=8 +# c2_eng_number_of_gfobt_kits_to_log=0 +# ## Spare subjects (subjects required for subject creation check = fit kits to log + gfobt kits to log + spare subject count) +# ## NOTE: Should be at least 1 due to poke the beast requiring a subject to log a fit kit for on National +# c2_spare_subject_count=5 + +# ---------------------------------- +# compartment 3 +# ---------------------------------- +# c3_eng_number_of_abnormal_gfobt_kits_to_log=0 +# c3_eng_number_of_normal_gfobt_kits_to_log=0 +# c3_eng_number_of_weak_positive_gfobt_kits_to_log=0 + c3_eng_number_of_abnormal_fit_kits=9 + c3_eng_number_of_normal_fit_kits=1 + +# ---------------------------------- +# compartment 4 +# ---------------------------------- +# # note there is a max of 16 slots available each day + c4_eng_number_of_appointments_to_book=6 + +# ---------------------------------- +# compartment 5 +# ---------------------------------- + c5_eng_number_of_screening_appts_to_attend=5 + +# ---------------------------------- +# compartment 6 +# ---------------------------------- + c6_eng_number_of_subjects_to_record=5 diff --git a/tests/smokescreen/test_compartment_1.py b/tests/smokescreen/test_compartment_1.py new file mode 100644 index 00000000..46ffd884 --- /dev/null +++ b/tests/smokescreen/test_compartment_1.py @@ -0,0 +1,104 @@ +import pytest +import logging +from pages.logout.log_out_page import LogoutPage +from utils.user_tools import UserTools +from pages.base_page import BasePage +from pages.call_and_recall.call_and_recall_page import CallAndRecallPage +from pages.call_and_recall.invitations_monitoring_page import InvitationsMonitoringPage +from pages.call_and_recall.invitations_plans_page import InvitationsPlansPage +from pages.call_and_recall.create_a_plan_page import CreateAPlanPage +from pages.call_and_recall.generate_invitations_page import GenerateInvitationsPage +from playwright.sync_api import Page +from utils.batch_processing import batch_processing + + +@pytest.mark.smokescreen +@pytest.mark.compartment1 +@pytest.mark.compartment1_plan_creation +def test_create_invitations_plan(page: Page, smokescreen_properties: dict) -> None: + """ + This is used to create the invitations plan. As it is not always needed it is separate to the main Compartment 1 function + """ + logging.info("Compartment 1 - Create Invitations Plan") + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + # Create plan - England + BasePage(page).go_to_call_and_recall_page() + CallAndRecallPage(page).go_to_planning_and_monitoring_page() + InvitationsMonitoringPage(page).go_to_invitation_plan_page( + smokescreen_properties["c1_screening_centre_code"] + ) + InvitationsPlansPage(page).go_to_create_a_plan_page() + logging.info("Setting daily invitation rate") + CreateAPlanPage(page).click_set_all_button() + CreateAPlanPage(page).fill_daily_invitation_rate_field( + smokescreen_properties["c1_daily_invitation_rate"] + ) + CreateAPlanPage(page).click_update_button() + CreateAPlanPage(page).click_confirm_button() + CreateAPlanPage(page).click_save_button() + CreateAPlanPage(page).fill_note_field("test data") + CreateAPlanPage(page).click_save_note_button() + InvitationsPlansPage(page).invitations_plans_title.wait_for() + logging.info("Invitation plan created") + + +@pytest.mark.vpn_required +@pytest.mark.smokescreen +@pytest.mark.compartment1 +def test_compartment_1(page: Page, smokescreen_properties: dict) -> None: + """ + This is the main compartment 1 function. It covers the following: + - Generating invitations based on the invitation plan + - Processes S1 (FIT) batches + - Processes S9 (FIT) batches + - Processes S10 (FIT) batches + """ + logging.info("Compartment 1 - Generate Invitations") + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + + # Generate Invitations + BasePage(page).go_to_call_and_recall_page() + CallAndRecallPage(page).go_to_generate_invitations_page() + logging.info("Generating invitations based on the invitations plan") + GenerateInvitationsPage(page).click_generate_invitations_button() + self_referrals_available = GenerateInvitationsPage( + page + ).wait_for_invitation_generation_complete( + int(smokescreen_properties["c1_daily_invitation_rate"]) + ) + + # Print the batch of Pre-Invitation Letters - England + logging.info("Compartment 1 - Process S1 Batch") + if self_referrals_available: + batch_processing( + page, + "S1", + "Pre-invitation (FIT) (digital leaflet)", + "S9 - Pre-invitation Sent", + ) + else: + logging.warning( + "Skipping S1 Pre-invitation (FIT) (digital leaflet) as no self referral invitations were generated" + ) + batch_processing( + page, "S1", "Pre-invitation (FIT)", "S9 - Pre-invitation Sent", True, True + ) + + # Print the batch of Invitation & Test Kit Letters - England + logging.info("Compartment 1 - Process S9 Batch") + batch_processing( + page, + "S9", + "Invitation & Test Kit (FIT)", + "S10 - Invitation & Test Kit Sent", + True, + ) + + # Print a set of reminder letters + logging.info("Compartment 1 - Process S10 Batch") + batch_processing( + page, "S10", "Test Kit Reminder", "S19 - Reminder of Initial Test Sent" + ) + + # Log out + LogoutPage(page).log_out() diff --git a/tests/smokescreen/test_compartment_2.py b/tests/smokescreen/test_compartment_2.py new file mode 100644 index 00000000..3d46e62e --- /dev/null +++ b/tests/smokescreen/test_compartment_2.py @@ -0,0 +1,75 @@ +import logging +from datetime import datetime +import pytest +from playwright.sync_api import Page +from pages.fit_test_kits.fit_test_kits_page import FITTestKitsPage +from pages.base_page import BasePage +from pages.logout.log_out_page import LogoutPage +from pages.fit_test_kits.log_devices_page import LogDevicesPage +from utils.batch_processing import batch_processing +from utils.fit_kit import FitKitGeneration +from utils.screening_subject_page_searcher import verify_subject_event_status_by_nhs_no +from utils.user_tools import UserTools + + +@pytest.mark.vpn_required +@pytest.mark.smokescreen +@pytest.mark.compartment2 +def test_compartment_2(page: Page, smokescreen_properties: dict) -> None: + """ + This is the main compartment 2 function. It covers the following: + - Obtaining test data from the DB + - Creating FIT Device IDs from the obtained test data + - Logging FIT Devices + - Logging FIT Devices as Spoilt + - Processing the generated S3 batch + """ + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + + BasePage(page).go_to_fit_test_kits_page() + FITTestKitsPage(page).go_to_log_devices_page() + + tk_type_id = smokescreen_properties["c2_fit_kit_tk_type_id"] + hub_id = smokescreen_properties["c2_fit_kit_logging_test_org_id"] + no_of_kits_to_retrieve = smokescreen_properties["c2_total_fit_kits_to_retieve"] + subjectdf = FitKitGeneration().create_fit_id_df(tk_type_id, hub_id, no_of_kits_to_retrieve) + for subject in range(int(smokescreen_properties["c2_normal_kits_to_log"])): + fit_device_id = subjectdf["fit_device_id"].iloc[subject] + logging.info(f"Logging FIT Device ID: {fit_device_id}") + LogDevicesPage(page).fill_fit_device_id_field(fit_device_id) + sample_date = datetime.now() + logging.info("Setting sample date to today's date") + LogDevicesPage(page).fill_sample_date_field(sample_date) + LogDevicesPage(page).log_devices_title.get_by_text("Scan Device").wait_for() + try: + LogDevicesPage(page).verify_successfully_logged_device_text() + logging.info(f"{fit_device_id} Successfully logged") + except Exception as e: + pytest.fail(f"{fit_device_id} unsuccessfully logged: {str(e)}") + + nhs_no = subjectdf["subject_nhs_number"].iloc[0] + verify_subject_event_status_by_nhs_no( + page, nhs_no, "S43 - Kit Returned and Logged (Initial Test)" + ) + + BasePage(page).click_main_menu_link() + BasePage(page).go_to_fit_test_kits_page() + FITTestKitsPage(page).go_to_log_devices_page() + spoilt_fit_device_id = subjectdf["fit_device_id"].iloc[-1] + logging.info(f"Logging Spoilt FIT Device ID: {spoilt_fit_device_id}") + LogDevicesPage(page).fill_fit_device_id_field(spoilt_fit_device_id) + LogDevicesPage(page).click_device_spoilt_button() + LogDevicesPage(page).select_spoilt_device_dropdown_option() + LogDevicesPage(page).click_log_as_spoilt_button() + try: + LogDevicesPage(page).verify_successfully_logged_device_text() + logging.info(f"{spoilt_fit_device_id} Successfully logged") + except Exception as e: + pytest.fail(f"{spoilt_fit_device_id} Unsuccessfully logged: {str(e)}") + + batch_processing( + page, "S3", "Retest (Spoilt) (FIT)", "S11 - Retest Kit Sent (Spoilt)" + ) + + # Log out + LogoutPage(page).log_out() diff --git a/tests/smokescreen/test_compartment_3.py b/tests/smokescreen/test_compartment_3.py new file mode 100644 index 00000000..8b9fa514 --- /dev/null +++ b/tests/smokescreen/test_compartment_3.py @@ -0,0 +1,84 @@ +import logging +import pytest +from playwright.sync_api import Page +from pages.logout.log_out_page import LogoutPage +from utils.batch_processing import batch_processing +from utils.fit_kit import FitKitLogged +from utils.screening_subject_page_searcher import verify_subject_event_status_by_nhs_no +from utils.oracle.oracle_specific_functions import ( + update_kit_service_management_entity, + execute_fit_kit_stored_procedures, +) +from utils.user_tools import UserTools + + +@pytest.mark.vpn_required +@pytest.mark.smokescreen +@pytest.mark.compartment3 +def test_compartment_3(page: Page, smokescreen_properties: dict) -> None: + """ + This is the main compartment 3 method + First it finds any relevant test data from the DB and stores it in a pandas dataframe + Then it separates it out into normal and abnormal results + Once that is done it updates a table on the DB and runs two stored procedures so that these subjects will not have normal/abnormal results on BCSS + It then checks that the status of the subject is as expected using the subject screening summary page + Then it process two batches performing checks on the subjects to ensure they always have the correct event status + """ + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + + + # Find data , separate it into normal and abnormal, Add results to the test records in the KIT_QUEUE table (i.e. mimic receiving results from the middleware) + # and get device IDs and their flags + device_ids =FitKitLogged().process_kit_data(smokescreen_properties) + # Retrieve NHS numbers for each device_id and determine normal/abnormal status + nhs_numbers = [] + normal_flags = [] + + for device_id, is_normal in device_ids: + nhs_number = update_kit_service_management_entity( + device_id, is_normal, smokescreen_properties + ) + nhs_numbers.append(nhs_number) + normal_flags.append( + is_normal + ) # Store the flag (True for normal, False for abnormal) + + # Run two stored procedures to process any kit queue records at status BCSS_READY + try: + execute_fit_kit_stored_procedures() + logging.info("Stored procedures executed successfully.") + except Exception as e: + logging.error(f"Error executing stored procedures: {str(e)}") + raise + + # Check the results of the processed FIT kits have correctly updated the status of the associated subjects + # Verify subject event status based on normal or abnormal classification + for nhs_number, is_normal in zip(nhs_numbers, normal_flags): + expected_status = ( + "S2 - Normal" if is_normal else "A8 - Abnormal" + ) # S2 for normal, A8 for abnormal + logging.info( + f"Verifying NHS number: {nhs_number} with expected status: {expected_status}" + ) + + verify_subject_event_status_by_nhs_no(page, nhs_number, expected_status) + + # Process S2 batch + batch_processing( + page, + "S2", + "Subject Result (Normal)", + "S158 - Subject Discharge Sent (Normal)", + True, + ) + + # Process S158 batch + batch_processing( + page, + "S158", + "GP Result (Normal)", + "S159 - GP Discharge Sent (Normal)", + ) + + # Log out + LogoutPage(page).log_out() diff --git a/tests/smokescreen/test_compartment_4.py b/tests/smokescreen/test_compartment_4.py new file mode 100644 index 00000000..276d94bf --- /dev/null +++ b/tests/smokescreen/test_compartment_4.py @@ -0,0 +1,155 @@ +import pytest +from playwright.sync_api import Page +from pages.logout.log_out_page import LogoutPage +from pages.base_page import BasePage +from pages.screening_practitioner_appointments.screening_practitioner_appointments_page import ( + ScreeningPractitionerAppointmentsPage, +) +from pages.screening_practitioner_appointments.set_availability_page import ( + SetAvailabilityPage, +) +from pages.screening_practitioner_appointments.practitioner_availability_page import ( + PractitionerAvailabilityPage, +) +from pages.screening_practitioner_appointments.colonoscopy_assessment_appointments_page import ( + ColonoscopyAssessmentAppointmentsPage, +) +from pages.screening_practitioner_appointments.book_appointment_page import ( + BookAppointmentPage, +) +from pages.screening_subject_search.subject_screening_summary_page import ( + SubjectScreeningSummaryPage, +) +from pages.screening_subject_search.episode_events_and_notes_page import ( + EpisodeEventsAndNotesPage, +) +from utils.user_tools import UserTools +from utils.calendar_picker import CalendarPicker +from utils.batch_processing import batch_processing +from datetime import datetime +from utils.oracle.oracle_specific_functions import get_subjects_for_appointments +from utils.nhs_number_tools import NHSNumberTools +import logging + + +@pytest.mark.vpn_required +@pytest.mark.smokescreen +@pytest.mark.compartment4 +def test_compartment_4(page: Page, smokescreen_properties: dict) -> None: + """ + This is the main compartment 4 method + First it obtains the necessary test data from the DB + Then it logs on as a Screening Centre Manager and sets the availability of a practitioner from 09:00 to 17:15 from todays date for the next 6 weeks + After It logs out an logs back in as a Hub Manager + Once logging back in it books appointments for the subjects retrieved earlier + Finally it processes the necessary batches to send out the letters and checks the subjects status has been updated to what is expected + """ + + subjects_df = get_subjects_for_appointments( + smokescreen_properties["c4_eng_number_of_appointments_to_book"] + ) + + logging.info( + f"Compartment 4 - Setting up appointments for {smokescreen_properties["c4_eng_weeks_to_make_available"]} Weeks" + ) + UserTools.user_login(page, "Screening Centre Manager at BCS001") + BasePage(page).go_to_screening_practitioner_appointments_page() + ScreeningPractitionerAppointmentsPage(page).go_to_set_availability_page() + SetAvailabilityPage(page).go_to_practitioner_availability_page() + PractitionerAvailabilityPage(page).select_site_dropdown_option( + smokescreen_properties["c4_eng_site_name1"] + ) + PractitionerAvailabilityPage(page).select_practitioner_dropdown_option( + smokescreen_properties["c4_eng_practitioner_name"] + ) + PractitionerAvailabilityPage(page).click_calendar_button() + CalendarPicker(page).select_day(datetime.today()) + PractitionerAvailabilityPage(page).click_show_button() + PractitionerAvailabilityPage(page).enter_start_time("09:00") + PractitionerAvailabilityPage(page).enter_end_time("17:15") + PractitionerAvailabilityPage(page).click_calculate_slots_button() + PractitionerAvailabilityPage(page).enter_number_of_weeks( + smokescreen_properties["c4_eng_weeks_to_make_available"] + ) + PractitionerAvailabilityPage(page).click_save_button() + PractitionerAvailabilityPage(page).slots_updated_message_is_displayed( + f"Slots Updated for {smokescreen_properties["c4_eng_weeks_to_make_available"]} Weeks" + ) + LogoutPage(page).log_out(close_page=False) + + logging.info( + f"Compartment 4 - Booking {smokescreen_properties["c4_eng_number_of_appointments_to_book"]} subjects to appointments" + ) + ScreeningPractitionerAppointmentsPage(page).go_to_log_in_page() + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + + BasePage(page).go_to_screening_practitioner_appointments_page() + ScreeningPractitionerAppointmentsPage(page).go_to_patients_that_require_page() + + for subject_num in range( + int(smokescreen_properties["c4_eng_number_of_appointments_to_book"]) + ): + nhs_number = subjects_df["subject_nhs_number"].iloc[subject_num] + logging.info(f"Booking appointment for: {nhs_number}") + + nhs_number_spaced = NHSNumberTools().spaced_nhs_number(nhs_number) + ColonoscopyAssessmentAppointmentsPage(page).filter_by_nhs_number(nhs_number) + ColonoscopyAssessmentAppointmentsPage(page).click_nhs_number_link( + nhs_number_spaced + ) + BookAppointmentPage(page).select_screening_centre_dropdown_option( + smokescreen_properties["c4_eng_centre_name"] + ) + BookAppointmentPage(page).select_site_dropdown_option( + [ + f"{smokescreen_properties["c4_eng_site_name2"]} (? km)", + f"{smokescreen_properties["c4_eng_site_name2"]} (? km) (attended)", + ] + ) + + current_month_displayed = BookAppointmentPage( + page + ).get_current_month_displayed() + CalendarPicker(page).book_first_eligible_appointment( + current_month_displayed, + BookAppointmentPage(page).appointment_cell_locators, + [ + BookAppointmentPage(page).available_background_colour, + BookAppointmentPage(page).some_available_background_colour, + ], + ) + BookAppointmentPage(page).appointments_table.click_first_input_in_column( + "Appt/Slot Time" + ) + BasePage(page).safe_accept_dialog(BookAppointmentPage(page).save_button) + try: + BookAppointmentPage(page).appointment_booked_confirmation_is_displayed( + "Appointment booked" + ) + logging.info(f"Appointment successfully booked for: {nhs_number}") + except Exception as e: + pytest.fail(f"Appointment not booked successfully: {e}") + BasePage(page).click_back_button() + ColonoscopyAssessmentAppointmentsPage(page).wait_for_page_header() + + logging.info("Compartment 4 - Sending out appointment invitations") + batch_processing( + page, + "A183", + "Practitioner Clinic 1st Appointment", + "A25 - 1st Colonoscopy Assessment Appointment Booked, letter sent", + ) + + batch_processing( + page, + "A183", + "GP Result (Abnormal)", + "A25 - 1st Colonoscopy Assessment Appointment Booked, letter sent", + ) + + SubjectScreeningSummaryPage(page).expand_episodes_list() + SubjectScreeningSummaryPage(page).click_first_fobt_episode_link() + EpisodeEventsAndNotesPage(page).expected_episode_event_is_displayed( + "A167 - GP Abnormal FOBT Result Sent" + ) + LogoutPage(page).log_out() diff --git a/tests/smokescreen/test_compartment_5.py b/tests/smokescreen/test_compartment_5.py new file mode 100644 index 00000000..7fa355bd --- /dev/null +++ b/tests/smokescreen/test_compartment_5.py @@ -0,0 +1,209 @@ +import pytest +from playwright.sync_api import Page +from pages.logout.log_out_page import LogoutPage +from pages.base_page import BasePage +from pages.screening_practitioner_appointments.appointment_calendar_page import ( + AppointmentCalendarPage, +) +from pages.screening_practitioner_appointments.appointment_detail_page import ( + AppointmentDetailPage, +) +from pages.screening_practitioner_appointments.screening_practitioner_appointments_page import ( + ScreeningPractitionerAppointmentsPage, +) +from pages.screening_practitioner_appointments.screening_practitioner_day_view_page import ( + ScreeningPractitionerDayViewPage, +) +from pages.datasets.subject_datasets_page import ( + SubjectDatasetsPage, +) +from pages.datasets.colonoscopy_dataset_page import ( + ColonoscopyDatasetsPage, + FitForColonoscopySspOptions, + AsaGradeOptions, +) +from pages.screening_subject_search.subject_screening_summary_page import ( + SubjectScreeningSummaryPage, +) +from pages.screening_subject_search.advance_fobt_screening_episode_page import ( + AdvanceFOBTScreeningEpisodePage, +) +from pages.screening_practitioner_appointments.screening_practitioner_day_view_page import ( + ScreeningPractitionerDayViewPage, +) +from pages.screening_practitioner_appointments.appointment_detail_page import ( + AppointmentDetailPage, +) +from pages.screening_practitioner_appointments.appointment_calendar_page import ( + AppointmentCalendarPage, +) +from pages.screening_subject_search.attend_diagnostic_test_page import ( + AttendDiagnosticTestPage, +) +from pages.screening_subject_search.subject_screening_summary_page import ( + SubjectScreeningSummaryPage, +) + +from pages.screening_subject_search.contact_with_patient_page import ( + ContactWithPatientPage, +) + +from utils.user_tools import UserTools +from utils.screening_subject_page_searcher import verify_subject_event_status_by_nhs_no +from utils.calendar_picker import CalendarPicker +from utils.oracle.oracle_specific_functions import get_subjects_with_booked_appointments +from datetime import datetime, timedelta +import logging + + +@pytest.mark.vpn_required +@pytest.mark.smokescreen +@pytest.mark.compartment5 +def test_compartment_5(page: Page, smokescreen_properties: dict) -> None: + """ + This is the main compartment 5 method + It involves marking the attendance of subjects to their screening practitioner appointments + Then it invites them for colonoscopy + Then it marks post investigation appointment as not required + """ + + subjects_df = get_subjects_with_booked_appointments( + smokescreen_properties["c5_eng_number_of_screening_appts_to_attend"] + ) + + UserTools.user_login(page, "Screening Centre Manager at BCS001") + + for subject_num in range(subjects_df.shape[0]): + + date_from_util = subjects_df["appointment_date"].iloc[subject_num].date() + + name_from_util = f"{str(subjects_df["person_given_name"].iloc[subject_num]).upper()} {str(subjects_df["person_family_name"].iloc[subject_num]).upper()}" + + nhs_no = subjects_df["subject_nhs_number"].iloc[subject_num] + + logging.info( + f"\nAttending appointment for:\nSubject Name: {name_from_util}\nSubject NHS no: {nhs_no}\nSubject Appointment Date: {date_from_util}" + ) + + BasePage(page).go_to_screening_practitioner_appointments_page() + ScreeningPractitionerAppointmentsPage(page).go_to_view_appointments_page() + AppointmentCalendarPage(page).select_appointment_type_dropdown( + smokescreen_properties["c5_eng_appointment_type"] + ) + AppointmentCalendarPage(page).select_screening_centre_dropdown( + smokescreen_properties["c5_eng_screening_centre"] + ) + AppointmentCalendarPage(page).select_site_dropdown( + smokescreen_properties["c5_eng_site"] + ) + + AppointmentCalendarPage(page).click_view_appointments_on_this_day_button() + + ScreeningPractitionerDayViewPage(page).select_practitioner_dropdown_option( + ["(all)", "(all having slots)"] + ) + ScreeningPractitionerDayViewPage(page).click_calendar_button() + CalendarPicker(page).v1_calender_picker(date_from_util) + + logging.info(f"Looking for {name_from_util}") + try: + ScreeningPractitionerDayViewPage(page).click_patient_link(name_from_util) + logging.info(f"Found and clicked {name_from_util}") + except Exception as e: + pytest.fail(f"Unable to find {name_from_util}: {e}") + + AppointmentDetailPage(page).check_attendance_radio() + AppointmentDetailPage(page).check_attended_check_box() + AppointmentDetailPage(page).click_calendar_button() + CalendarPicker(page).v1_calender_picker(datetime.today() - timedelta(1)) + AppointmentDetailPage(page).click_save_button() + try: + AppointmentDetailPage(page).verify_text_visible("Record updated") + logging.info( + f"Subject attended appointment - Record successfully updated for: {name_from_util}" + ) + except Exception: + pytest.fail( + f"Subject not attended appointment - Record unsuccessfully updated for: {name_from_util}" + ) + BasePage(page).click_main_menu_link() + + for subject_num in range(subjects_df.shape[0]): + + nhs_no = subjects_df["subject_nhs_number"].iloc[subject_num] + + verify_subject_event_status_by_nhs_no( + page, nhs_no, "J10 - Attended Colonoscopy Assessment Appointment" + ) + + SubjectScreeningSummaryPage(page).click_datasets_link() + SubjectDatasetsPage(page).click_colonoscopy_show_datasets() + + ColonoscopyDatasetsPage(page).select_asa_grade_option(AsaGradeOptions.FIT.value) + ColonoscopyDatasetsPage(page).select_fit_for_colonoscopy_option( + FitForColonoscopySspOptions.YES.value + ) + ColonoscopyDatasetsPage(page).click_dataset_complete_radio_button_yes() + ColonoscopyDatasetsPage(page).save_dataset() + BasePage(page).click_back_button() + BasePage(page).click_back_button() + + SubjectScreeningSummaryPage(page).click_advance_fobt_screening_episode_button() + AdvanceFOBTScreeningEpisodePage( + page + ).click_suitable_for_endoscopic_test_button() + + AdvanceFOBTScreeningEpisodePage(page).click_calendar_button() + CalendarPicker(page).v1_calender_picker(datetime.today()) + + AdvanceFOBTScreeningEpisodePage(page).select_test_type_dropdown_option( + "Colonoscopy" + ) + + logging.info(f"Inviting {name_from_util} to diagnostic test") + AdvanceFOBTScreeningEpisodePage(page).click_invite_for_diagnostic_test_button() + AdvanceFOBTScreeningEpisodePage(page).verify_latest_event_status_value( + "A59 - Invited for Diagnostic Test" + ) + + logging.info(f"{name_from_util} attended diagnostic test") + AdvanceFOBTScreeningEpisodePage(page).click_attend_diagnostic_test_button() + + AttendDiagnosticTestPage(page).select_actual_type_of_test_dropdown_option( + "Colonoscopy" + ) + AttendDiagnosticTestPage(page).click_calendar_button() + CalendarPicker(page).v1_calender_picker(datetime.today()) + AttendDiagnosticTestPage(page).click_save_button() + SubjectScreeningSummaryPage(page).verify_latest_event_status_value( + "A259 - Attended Diagnostic Test" + ) + + SubjectScreeningSummaryPage(page).click_advance_fobt_screening_episode_button() + + AdvanceFOBTScreeningEpisodePage(page).click_other_post_investigation_button() + AdvanceFOBTScreeningEpisodePage(page).verify_latest_event_status_value( + "A361 - Other Post-investigation Contact Required" + ) + + AdvanceFOBTScreeningEpisodePage( + page + ).click_record_other_post_investigation_contact_button() + + ContactWithPatientPage(page).select_direction_dropdown_option("To patient") + ContactWithPatientPage(page).select_caller_id_dropdown_index_option(1) + ContactWithPatientPage(page).click_calendar_button() + CalendarPicker(page).v1_calender_picker(datetime.today()) + ContactWithPatientPage(page).enter_start_time("11:00") + ContactWithPatientPage(page).enter_end_time("12:00") + ContactWithPatientPage(page).enter_discussion_record_text("Test Automation") + ContactWithPatientPage(page).select_outcome_dropdown_option( + "Post-investigation Appointment Not Required" + ) + ContactWithPatientPage(page).click_save_button() + + verify_subject_event_status_by_nhs_no( + page, nhs_no, "A323 - Post-investigation Appointment NOT Required" + ) + + LogoutPage(page).log_out() diff --git a/tests/smokescreen/test_compartment_6.py b/tests/smokescreen/test_compartment_6.py new file mode 100644 index 00000000..538d586d --- /dev/null +++ b/tests/smokescreen/test_compartment_6.py @@ -0,0 +1,171 @@ +import pytest +from playwright.sync_api import Page +from utils.user_tools import UserTools +from utils.screening_subject_page_searcher import verify_subject_event_status_by_nhs_no +from utils.batch_processing import batch_processing +from pages.logout.log_out_page import LogoutPage +from utils.oracle.oracle_specific_functions import ( + get_subjects_for_investigation_dataset_updates, +) +from utils.subject_demographics import SubjectDemographicUtil +from utils.investigation_dataset import ( + InvestigationDatasetCompletion, + InvestigationDatasetResults, + AfterInvestigationDatasetComplete, +) +import logging + + +@pytest.mark.vpn_required +@pytest.mark.smokescreen +@pytest.mark.compartment6 +def test_compartment_6(page: Page, smokescreen_properties: dict) -> None: + """ + This is the main compartment 6 method + This test fills out the investigation datasets for different subjects to get different outcomes for a diagnostic test + based on the test results and the subject's age, then prints the diagnostic test result letters. + If the subject is old enough and they get a high-risk or LNPCP result, then they are handed over + into symptomatic care, and the relevant letters are printed. + Here old refers to if a subject is over 75 at recall + """ + + # For the following tests 'old' refers to if a subject is over 75 at recall + # The recall period is 2 years from the last diagnostic test for a Normal or Abnormal diagnostic test result + # or 3 years for someone who is going in to Surveillance (High-risk findings or LNPCP) + + UserTools.user_login(page, "Screening Centre Manager at BCS001") + + # Older patient - High Risk Result + logging.info("High-risk result for an older subject") + subjects_df = get_subjects_for_investigation_dataset_updates( + smokescreen_properties["c6_eng_number_of_subjects_to_record"], + smokescreen_properties["c6_eng_org_id"], + ) + logging.info("Fetched subjects for investigation dataset updates.") + nhs_no = subjects_df["subject_nhs_number"].iloc[0] + logging.info(f"Selected NHS number for older subject: {nhs_no}") + SubjectDemographicUtil(page).update_subject_dob(nhs_no, True, False) + logging.info( + f"Updated date of birth for NHS number {nhs_no} to indicate an older subject." + ) + InvestigationDatasetCompletion(page).complete_with_result( + nhs_no, InvestigationDatasetResults.HIGH_RISK + ) + logging.info( + f"Completed investigation dataset for NHS number {nhs_no} with result: HIGH_RISK." + ) + AfterInvestigationDatasetComplete(page).progress_episode_based_on_result( + InvestigationDatasetResults.HIGH_RISK, False + ) + logging.info( + f"Progressed episode for NHS number {nhs_no} based on result: HIGH_RISK." + ) + + # Younger patient - High Risk Result + logging.info("High-risk result for a younger subject") + nhs_no = subjects_df["subject_nhs_number"].iloc[1] + logging.info(f"Selected NHS number for younger subject: {nhs_no}") + SubjectDemographicUtil(page).update_subject_dob(nhs_no, True, True) + logging.info( + f"Updated date of birth for NHS number {nhs_no} to indicate a younger subject." + ) + InvestigationDatasetCompletion(page).complete_with_result( + nhs_no, InvestigationDatasetResults.HIGH_RISK + ) + logging.info( + f"Completed investigation dataset for NHS number {nhs_no} with result: HIGH_RISK." + ) + AfterInvestigationDatasetComplete(page).progress_episode_based_on_result( + InvestigationDatasetResults.HIGH_RISK, True + ) + logging.info( + f"Progressed episode for NHS number {nhs_no} based on result: HIGH_RISK." + ) + + # Older patient - LNPCP Result + logging.info("LNPCP result for an older subject") + nhs_no = subjects_df["subject_nhs_number"].iloc[2] + logging.info(f"Selected NHS number for older subject: {nhs_no}") + SubjectDemographicUtil(page).update_subject_dob(nhs_no, True, False) + logging.info( + f"Updated date of birth for NHS number {nhs_no} to indicate an older subject." + ) + InvestigationDatasetCompletion(page).complete_with_result( + nhs_no, InvestigationDatasetResults.LNPCP + ) + logging.info( + f"Completed investigation dataset for NHS number {nhs_no} with result: LNPCP." + ) + AfterInvestigationDatasetComplete(page).progress_episode_based_on_result( + InvestigationDatasetResults.LNPCP, False + ) + logging.info(f"Progressed episode for NHS number {nhs_no} based on result: LNPCP.") + + # Younger patient - LNPCP Result + logging.info("LNPCP result for a younger subject") + nhs_no = subjects_df["subject_nhs_number"].iloc[3] + logging.info(f"Selected NHS number for younger subject: {nhs_no}") + SubjectDemographicUtil(page).update_subject_dob(nhs_no, True, True) + logging.info( + f"Updated date of birth for NHS number {nhs_no} to indicate a younger subject." + ) + InvestigationDatasetCompletion(page).complete_with_result( + nhs_no, InvestigationDatasetResults.LNPCP + ) + logging.info( + f"Completed investigation dataset for NHS number {nhs_no} with result: LNPCP." + ) + AfterInvestigationDatasetComplete(page).progress_episode_based_on_result( + InvestigationDatasetResults.LNPCP, True + ) + logging.info(f"Progressed episode for NHS number {nhs_no} based on result: LNPCP.") + + # Any patient - Normal Result + logging.info("Normal result for any age subject") + nhs_no_normal = subjects_df["subject_nhs_number"].iloc[4] + logging.info(f"Selected NHS number for normal result: {nhs_no_normal}") + InvestigationDatasetCompletion(page).complete_with_result( + nhs_no_normal, InvestigationDatasetResults.NORMAL + ) + logging.info( + f"Completed investigation dataset for NHS number {nhs_no_normal} with result: NORMAL." + ) + AfterInvestigationDatasetComplete(page).progress_episode_based_on_result( + InvestigationDatasetResults.NORMAL, True + ) + logging.info( + f"Progressed episode for NHS number {nhs_no_normal} based on result: NORMAL." + ) + # Batch processing for result letters + logging.info("Starting batch processing for result letters.") + batch_processing( + page, + "A318", + "Result Letters - No Post-investigation Appointment", + [ + "S61 - Normal (No Abnormalities Found)", + "A158 - High-risk findings", + "A157 - LNPCP", + ], + ) + + # This is to check for the status of a normal subject as this NHS Number cannot be retrieved from the DB + verify_subject_event_status_by_nhs_no( + page, nhs_no_normal, "S61 - Normal (No Abnormalities Found)" + ) + + batch_processing( + page, + "A385", + "Handover into Symptomatic Care Adenoma Surveillance, Age - GP Letter", + "A382 - Handover into Symptomatic Care - GP Letter Printed", + ) + + batch_processing( + page, + "A382", + "Handover into Symptomatic Care Adenoma Surveillance - Patient Letter", + "P202 - Waiting Completion of Outstanding Events", + ) + + LogoutPage(page).log_out() diff --git a/tests/test_bcss_19181_users_permit_list.py b/tests/test_bcss_19181_users_permit_list.py new file mode 100644 index 00000000..5b855027 --- /dev/null +++ b/tests/test_bcss_19181_users_permit_list.py @@ -0,0 +1,50 @@ +import pytest +from playwright.sync_api import Page +from utils.user_tools import UserTools +from pages.logout import log_out_page as logout +from pages.login import login_failure_screen_page as login_failure +from pages import base_page as bcss_home +from utils.oracle.oracle import OracleDB + + +@pytest.fixture(scope="function", autouse=True) +def before_test(page: Page): + """ + This fixture confirms that users can log in successfully in to BCSS whilst the approved users list is empty + """ + # Log in to BCSS as bcss401 user, then log out + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + bcss_home.BasePage(page).bowel_cancer_screening_system_header_is_displayed() + bcss_home.BasePage(page).click_log_out_link() + logout.LogoutPage(page).verify_log_out_page() + # Log in to BCSS as bcss118 user, then log out + UserTools.user_login(page, "Screening Centre Manager at BCS001") + bcss_home.BasePage(page).bowel_cancer_screening_system_header_is_displayed() + bcss_home.BasePage(page).click_log_out_link() + logout.LogoutPage(page).verify_log_out_page() + + yield + OracleDB().delete_all_users_from_approved_users_table() + + +@pytest.mark.vpn_required +@pytest.mark.smoke +def test_only_users_on_approved_can_login_to_bcss(page: Page) -> None: + # Add bcss401 user to approved users list table + OracleDB().populate_ui_approved_users_table("BCSS401") + # BCSS401 user successfully logs in to BCSS whilst on the approved list + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + bcss_home.BasePage(page).bowel_cancer_screening_system_header_is_displayed() + # BCSS401 user logs out + bcss_home.BasePage(page).click_log_out_link() + logout.LogoutPage(page).verify_log_out_page() + + # BCSS118 user fails to logs in to BCSS as they are not on the approved list + UserTools.user_login(page, "Screening Centre Manager at BCS001") + # Verify relevant error message is displayed + login_failure.LoginFailureScreenPage( + page + ).verify_login_failure_screen_is_displayed() + page.close() + # Delete all users from approved users list table + # Delete function is called with the yield command in the fixture to make sure it clears the table even when the test fails diff --git a/tests/test_bowel_scope_page.py b/tests/test_bowel_scope_page.py new file mode 100644 index 00000000..f5cee95c --- /dev/null +++ b/tests/test_bowel_scope_page.py @@ -0,0 +1,33 @@ +import pytest +from playwright.sync_api import Page +from pages.base_page import BasePage +from pages.bowel_scope.bowel_scope_page import BowelScopePage +from pages.bowel_scope.bowel_scope_appointments_page import BowelScopeAppointmentsPage +from utils.user_tools import UserTools + + +@pytest.fixture(scope="function", autouse=True) +def before_each(page: Page): + """ + Before every test is executed, this fixture logs in to BCSS as a test user and navigates to the bowel scope page + """ + # Log in to BCSS + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + + # Go to bowel scope page + BasePage(page).go_to_bowel_scope_page() + + +@pytest.mark.smoke +def test_bowel_scope_page_navigation(page: Page) -> None: + """ + Confirms that the bowel scope appointments page loads, the appointments calendar is displayed and the + main menu button returns the user to the main menu + """ + # Bowel scope appointments page loads as expected + BowelScopePage(page).go_to_bowel_scope_page() + BowelScopeAppointmentsPage(page).verify_page_title() + + # Return to main menu + BasePage(page).click_main_menu_link() + BasePage(page).main_menu_header_is_displayed() diff --git a/tests/test_calendar_picker_methods.py b/tests/test_calendar_picker_methods.py new file mode 100644 index 00000000..5e72b243 --- /dev/null +++ b/tests/test_calendar_picker_methods.py @@ -0,0 +1,66 @@ +from pages.screening_subject_search.subject_screening_search_page import ( + SubjectScreeningPage, +) +from pages.communication_production.communications_production_page import ( + CommunicationsProductionPage, +) +from pages.communication_production.batch_list_page import ActiveBatchListPage +import pytest +from playwright.sync_api import Page +from pages.base_page import BasePage +from utils.user_tools import UserTools +from utils.date_time_utils import DateTimeUtils +from datetime import datetime +from sys import platform + + +@pytest.mark.smoke +def test_calender_picker_v1(page: Page) -> None: + """ + This test is used to verify that the v1 calendar picker in utils/calendar_picker.py works as intended + This uses the subject screening search page in order to do so + """ + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + BasePage(page).go_to_screening_subject_search_page() + SubjectScreeningPage(page).select_dob_using_calendar_picker(datetime(2021, 12, 1)) + SubjectScreeningPage(page).verify_date_of_birth_filter_input("01/12/2021") + SubjectScreeningPage(page).click_clear_filters_button() + SubjectScreeningPage(page).select_dob_using_calendar_picker(datetime(2020, 3, 30)) + SubjectScreeningPage(page).verify_date_of_birth_filter_input("30/03/2020") + SubjectScreeningPage(page).click_clear_filters_button() + SubjectScreeningPage(page).select_dob_using_calendar_picker(datetime(2019, 11, 27)) + SubjectScreeningPage(page).verify_date_of_birth_filter_input("27/11/2019") + SubjectScreeningPage(page).click_clear_filters_button() + SubjectScreeningPage(page).select_dob_using_calendar_picker(datetime.today()) + SubjectScreeningPage(page).verify_date_of_birth_filter_input( + str(datetime.today().strftime("%d/%m/%Y")) + ) + + +@pytest.mark.smoke +def test_calender_picker_v2(page: Page) -> None: + """ + This test is used to verify that the v2 calendar picker in utils/calendar_picker.py works as intended + This uses the active batch list page in order to do so + """ + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + BasePage(page).go_to_communications_production_page() + CommunicationsProductionPage(page).go_to_active_batch_list_page() + ActiveBatchListPage(page).enter_deadline_date_filter(datetime(1961, 12, 30)) + ActiveBatchListPage(page).verify_deadline_date_filter_input("30 Nov 1961") + ActiveBatchListPage(page).clear_deadline_filter_date() + ActiveBatchListPage(page).enter_deadline_date_filter(datetime(2026, 12, 1)) + ActiveBatchListPage(page).verify_deadline_date_filter_input("1 Dec 2026") + ActiveBatchListPage(page).clear_deadline_filter_date() + ActiveBatchListPage(page).enter_deadline_date_filter(datetime(1989, 6, 15)) + ActiveBatchListPage(page).verify_deadline_date_filter_input("15 Jun 1989") + ActiveBatchListPage(page).clear_deadline_filter_date() + ActiveBatchListPage(page).enter_deadline_date_filter(datetime.today()) + if platform == "win32": # Windows + ActiveBatchListPage(page).verify_deadline_date_filter_input( + str(DateTimeUtils.format_date(datetime.today(), "%#d %b %Y")) + ) + else: # Linux or Mac + ActiveBatchListPage(page).verify_deadline_date_filter_input( + str(DateTimeUtils.format_date(datetime.today(), "%-d %b %Y")) + ) diff --git a/tests/test_call_and_recall_page.py b/tests/test_call_and_recall_page.py new file mode 100644 index 00000000..00ae0560 --- /dev/null +++ b/tests/test_call_and_recall_page.py @@ -0,0 +1,80 @@ +import pytest +from playwright.sync_api import Page +from pages.base_page import BasePage +from pages.call_and_recall.call_and_recall_page import CallAndRecallPage +from pages.call_and_recall.invitations_monitoring_page import InvitationsMonitoringPage +from pages.call_and_recall.generate_invitations_page import GenerateInvitationsPage +from pages.call_and_recall.non_invitations_days_page import NonInvitationDaysPage +from pages.call_and_recall.age_extension_rollout_plans_page import ( + AgeExtensionRolloutPlansPage, +) +from pages.call_and_recall.invitations_plans_page import InvitationsPlansPage +from pages.call_and_recall.create_a_plan_page import CreateAPlanPage +from utils.user_tools import UserTools + + +@pytest.fixture(scope="function", autouse=True) +def before_each(page: Page): + """ + Before every test is executed, this fixture logs in to BCSS as a test user and navigates to the call and recall page + """ + # Log in to BCSS + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + + # Go to call and recall page + BasePage(page).go_to_call_and_recall_page() + + +@pytest.mark.smoke +def test_call_and_recall_page_navigation(page: Page) -> None: + """ + Confirms that the Call and Recall menu displays all menu options and confirms they load the correct pages + """ + # Planning and monitoring page loads as expected + CallAndRecallPage(page).go_to_planning_and_monitoring_page() + InvitationsMonitoringPage(page).verify_invitations_monitoring_title() + BasePage(page).click_back_button() + + # Generate invitations page loads as expected + CallAndRecallPage(page).go_to_generate_invitations_page() + GenerateInvitationsPage(page).verify_generate_invitations_title() + BasePage(page).click_back_button() + + # Invitation generation progress page loads as expected + CallAndRecallPage(page).go_to_invitation_generation_progress_page() + GenerateInvitationsPage(page).verify_invitation_generation_progress_title() + BasePage(page).click_back_button() + + # Non invitation days page loads as expected + CallAndRecallPage(page).go_to_non_invitation_days_page() + NonInvitationDaysPage(page).verify_non_invitation_days_tile() + BasePage(page).click_back_button() + + # Age extension rollout page loads as expected + CallAndRecallPage(page).go_to_age_extension_rollout_plans_page() + AgeExtensionRolloutPlansPage(page).verify_age_extension_rollout_plans_title() + BasePage(page).click_back_button() + + # Return to main menu + BasePage(page).click_main_menu_link() + BasePage(page).main_menu_header_is_displayed() + + +@pytest.mark.smoke +def test_view_an_invitation_plan(page: Page, general_properties: dict) -> None: + """ + Confirms that an invitation plan can be viewed via a screening centre from the planning ad monitoring page + """ + # Go to planning and monitoring page + CallAndRecallPage(page).go_to_planning_and_monitoring_page() + + # Select a screening centre + InvitationsMonitoringPage(page).go_to_invitation_plan_page( + general_properties["screening_centre_code"] + ) + + # Select an invitation plan + InvitationsPlansPage(page).go_to_first_available_plan() + + # Verify invitation page is displayed + CreateAPlanPage(page).verify_create_a_plan_title() diff --git a/tests/test_communications_production_page.py b/tests/test_communications_production_page.py new file mode 100644 index 00000000..b58588ff --- /dev/null +++ b/tests/test_communications_production_page.py @@ -0,0 +1,72 @@ +import pytest +from playwright.sync_api import Page +from pages.base_page import BasePage +from pages.communication_production.communications_production_page import ( + CommunicationsProductionPage, +) +from pages.communication_production.batch_list_page import ( + ActiveBatchListPage, + ArchivedBatchListPage, +) +from pages.communication_production.letter_library_index_page import ( + LetterLibraryIndexPage, +) +from pages.communication_production.letter_signatory_page import LetterSignatoryPage +from pages.communication_production.electronic_communications_management_page import ( + ElectronicCommunicationManagementPage, +) +from utils.user_tools import UserTools + + +@pytest.fixture(scope="function", autouse=True) +def before_each(page: Page): + """ + Before every test is executed, this fixture logs in to BCSS as a test user and navigates to the communications + production page + """ + # Log in to BCSS + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + + # Go to communications production page + BasePage(page).go_to_communications_production_page() + + +@pytest.mark.smoke +def test_communications_production_page_navigation(page: Page) -> None: + """ + Confirms all menu items are displayed on the communications production page, and that the relevant pages + are loaded when the links are clicked + """ + # Active batch list page loads as expected + CommunicationsProductionPage(page).go_to_active_batch_list_page() + ActiveBatchListPage(page).verify_batch_list_page_title("Active Batch List") + BasePage(page).click_back_button() + + # Archived batch list page loads as expected + CommunicationsProductionPage(page).go_to_archived_batch_list_page() + ArchivedBatchListPage(page).verify_batch_list_page_title("Archived Batch List") + BasePage(page).click_back_button() + + # Letter library index page loads as expected + CommunicationsProductionPage(page).go_to_letter_library_index_page() + LetterLibraryIndexPage(page).verify_letter_library_index_title() + BasePage(page).click_back_button() + + # Manage individual letter link is visible (not clickable due to user role permissions) + CommunicationsProductionPage(page).verify_manage_individual_letter_page_visible() + + # Letter signatory page loads as expected + CommunicationsProductionPage(page).go_to_letter_signatory_page() + LetterSignatoryPage(page).verify_letter_signatory_title() + BasePage(page).click_back_button() + + # Electronic communication management page loads as expected + CommunicationsProductionPage(page).go_to_electronic_communication_management_page() + ElectronicCommunicationManagementPage( + page + ).verify_electronic_communication_management_title() + + # Return to main menu + # main_menu_link = page.get_by_role("link", name="Main Menu") + BasePage(page).click_main_menu_link() + BasePage(page).main_menu_header_is_displayed() diff --git a/tests/test_contacts_list_page.py b/tests/test_contacts_list_page.py new file mode 100644 index 00000000..96b89df3 --- /dev/null +++ b/tests/test_contacts_list_page.py @@ -0,0 +1,56 @@ +import pytest +from playwright.sync_api import Page +from pages.base_page import BasePage +from pages.contacts_list.contacts_list_page import ContactsListPage +from pages.contacts_list.view_contacts_page import ViewContactsPage +from pages.contacts_list.edit_my_contact_details_page import EditMyContactDetailsPage +from pages.contacts_list.maintain_contacts_page import MaintainContactsPage +from pages.contacts_list.my_preference_settings_page import MyPreferenceSettingsPage +from utils.user_tools import UserTools + + +@pytest.fixture(scope="function", autouse=True) +def before_each(page: Page): + """ + Before every test is executed, this fixture logs in to BCSS as a test user and navigates to the contacts list page + """ + # Log in to BCSS + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + + # Go to contacts list page + BasePage(page).go_to_contacts_list_page() + + +@pytest.mark.smoke +def test_contacts_list_page_navigation(page: Page) -> None: + """ + Confirms all menu items are displayed on the contacts list page, and that the relevant pages + are loaded when the links are clicked + """ + # View contacts page loads as expected + ContactsListPage(page).go_to_view_contacts_page() + ViewContactsPage(page).verify_view_contacts_title() + BasePage(page).click_back_button() + + # Edit my contact details page loads as expected + ContactsListPage(page).go_to_edit_my_contact_details_page() + EditMyContactDetailsPage(page).verify_edit_my_contact_details_title() + BasePage(page).click_back_button() + + # Maintain contacts page loads as expected + ContactsListPage(page).go_to_maintain_contacts_page() + MaintainContactsPage(page).verify_maintain_contacts_title() + BasePage(page).click_back_button() + + # My preference settings page loads as expected + ContactsListPage(page).go_to_my_preference_settings_page() + MyPreferenceSettingsPage(page).verify_my_preference_settings_title() + BasePage(page).click_back_button() + + # Other links are visible (Not clickable due to user role permissions) + ContactsListPage(page).verify_extract_contact_details_page_visible() + ContactsListPage(page).verify_resect_and_discard_accredited_page_visible() + + # Return to main menu + BasePage(page).click_main_menu_link() + BasePage(page).main_menu_header_is_displayed() diff --git a/tests/test_download_page.py b/tests/test_download_page.py new file mode 100644 index 00000000..ecbab0e6 --- /dev/null +++ b/tests/test_download_page.py @@ -0,0 +1,63 @@ +import pytest +from playwright.sync_api import Page +from pages.base_page import BasePage +from pages.download.downloads_page import DownloadsPage +from pages.download.individual_download_request_and_retrieval_page import ( + IndividualDownloadRequestAndRetrievalPage, +) +from pages.download.list_of_individual_downloads_page import ( + ListOfIndividualDownloadsPage, +) +from pages.download.batch_download_request_and_retrieval_page import ( + BatchDownloadRequestAndRetrievalPage, +) +from utils.user_tools import UserTools + + +@pytest.fixture(scope="function", autouse=True) +def before_each(page: Page): + """ + Before every test is executed, this fixture logs in to BCSS as a test user and navigates to the download page + """ + # Log in to BCSS + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + + # Go to download page + BasePage(page).go_to_download_page() + + +@pytest.mark.smoke +def test_download_facility_page_navigation(page: Page) -> None: + """ + Confirms all menu items are displayed on the downloads page, and that the relevant pages + are loaded when the links are clicked. Also confirms that the warning header messages are displayed + on the relevant pages + """ + # Individual download request and retrieval page loads as expected + DownloadsPage(page).go_to_individual_download_request_page() + IndividualDownloadRequestAndRetrievalPage( + page + ).verify_individual_download_request_and_retrieval_title() + + # Individual download request and retrieval page contains warning message + IndividualDownloadRequestAndRetrievalPage(page).expect_form_to_have_warning() + BasePage(page).click_back_button() + + # List of Individual downloads page loads as expected + DownloadsPage(page).go_to_list_of_individual_downloads_page() + ListOfIndividualDownloadsPage(page).verify_list_of_individual_downloads_title() + BasePage(page).click_back_button() + + # Batch download request and retrieval page loads as expected + DownloadsPage(page).go_to_batch_download_request_and_page() + BatchDownloadRequestAndRetrievalPage( + page + ).verify_batch_download_request_and_retrieval_title() + + # Batch download request and retrieval page contains warning message + BatchDownloadRequestAndRetrievalPage(page).expect_form_to_have_warning() + BasePage(page).click_back_button() + + # Return to main menu + BasePage(page).click_main_menu_link() + BasePage(page).main_menu_header_is_displayed() diff --git a/tests/test_example.py b/tests/test_example.py deleted file mode 100644 index a6b86845..00000000 --- a/tests/test_example.py +++ /dev/null @@ -1,78 +0,0 @@ -""" -This file provides a very basic test to confirm how to get started with test execution, and also -a way to prove that the blueprint has been copied and built correctly for teams getting stated. - -You can invoke this test once the blueprint has been installed by using the following command -to see the test executing and producing a trace report: - pytest --tracing on --headed -""" - -import pytest -from playwright.sync_api import Page, expect - - -@pytest.fixture(autouse=True) -def initial_navigation(page: Page) -> None: - ''' - This fixture (or hook) is used for each test in this file to navigate to this repository before - each test, to reduce the need for repeated code within the tests directly. - - This specific fixture has been designated to run for every test by setting autouse=True. - ''' - - # Navigate to page - page.goto("https://github.com/nhs-england-tools/playwright-python-blueprint") - - -@pytest.mark.example -def test_basic_example(page: Page) -> None: - ''' - This test demonstrates how to quickly get started using Playwright Python, which runs using pytest. - - This example starts with @pytest.mark.example, which indicates this test has been tagged - with the term "example", to demonstrate how tests can be independently tagged. - - When running using the pytest command, Playwright automatically instantiates certain objects - available for use, including the Page object (which is how Playwright interacts with the - system under test). - - This test does the following: - 1) Navigates to this repository (via the initial_navigation fixture above) - 2) Asserts that the README contents rendered by GitHub contains the text "Playwright Python Blueprint" - 3) Asserts that the main section of the page contains the topic label "playwright-python" - ''' - - # Assert repo text is present - expect(page.get_by_role("article")).to_contain_text("Playwright Python Blueprint") - - # Assert the page loaded contains a reference to the playwright-python topic page - expect(page.get_by_role("main")).to_contain_text("playwright-python") - - -@pytest.mark.example -def test_textbox_example(page: Page) -> None: - """ - This test demonstrates another example of quickly getting started using Playwright Python. - - This is specifically designed to outline some of the principals that Playwright uses, for - example when looking for a specific textbox to enter information into, rather than using a - direct HTML or CSS reference, you can use attributes of the field (in this case the placeholder - text) to find the element as a user would navigating your application. You can also use - locators to find specific HTML or CSS elements as required (in this case the locator for the - assertion). - - This test does the following: - 1) Navigates to this repository (via the initial_navigation fixture above) - 2) Uses the "Go to file" textbox and searches for this file, "text_example.py" - 3) Selects the label for the dropdown element presented for the search results and clicks - 4) Asserts that the filename for the now selected file is "test_example.py" - """ - - # Select the "Go to file" textbox and search for this file - page.get_by_placeholder("Go to file").fill("test_example.py") - - # Click the file name presented in the dropdown - page.get_by_label("tests/test_example.").click() - - # Confirm we are viewing the correct file - expect(page.locator("#file-name-id-wide")).to_contain_text("test_example.py") diff --git a/tests/test_fit_test_kits_page.py b/tests/test_fit_test_kits_page.py new file mode 100644 index 00000000..8e7a6cca --- /dev/null +++ b/tests/test_fit_test_kits_page.py @@ -0,0 +1,100 @@ +import pytest +from playwright.sync_api import Page +from pages.base_page import BasePage +from pages.fit_test_kits.fit_test_kits_page import FITTestKitsPage +from pages.fit_test_kits.fit_rollout_summary_page import FITRolloutSummaryPage +from pages.fit_test_kits.log_devices_page import LogDevicesPage +from pages.fit_test_kits.view_fit_kit_result_page import ViewFITKitResultPage +from pages.fit_test_kits.kit_service_management_page import KitServiceManagementPage +from pages.fit_test_kits.kit_result_audit_page import KitResultAuditPage +from pages.fit_test_kits.view_algorithms_page import ViewAlgorithmsPage +from pages.fit_test_kits.view_screening_centre_fit_configuration_page import ( + ViewScreeningCentreFITConfigurationPage, +) +from pages.fit_test_kits.screening_incidents_list_page import ScreeningIncidentsListPage +from pages.fit_test_kits.manage_qc_products_page import ManageQCProductsPage +from pages.fit_test_kits.maintain_analysers_page import MaintainAnalysersPage +from utils.user_tools import UserTools + + +@pytest.fixture(scope="function", autouse=True) +def before_each(page: Page): + """ + Before every test is executed, this fixture logs in to BCSS as a test user and navigates to the + fit test kits page + """ + # Log in to BCSS + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + + # Go to fit test kits page + BasePage(page).go_to_fit_test_kits_page() + + +@pytest.mark.smoke +def test_fit_test_kits_page_navigation(page: Page, general_properties: dict) -> None: + """ + Confirms all menu items are displayed on the fit test kits page, and that the relevant pages + are loaded when the links are clicked + """ + # Verify FIT rollout summary page opens as expected + FITTestKitsPage(page).go_to_fit_rollout_summary_page() + FITRolloutSummaryPage(page).verify_fit_rollout_summary_body() + BasePage(page).click_back_button() + + # Verify Log Devices page opens as expected + FITTestKitsPage(page).go_to_log_devices_page() + LogDevicesPage(page).verify_log_devices_title() + BasePage(page).click_back_button() + + # Verify View FIT Kit Result page opens as expected + FITTestKitsPage(page).go_to_view_fit_kit_result() + ViewFITKitResultPage(page).verify_view_fit_kit_result_body() + BasePage(page).click_back_button() + + # Verify Kit Service Management page opens as expected + FITTestKitsPage(page).go_to_kit_service_management() + KitServiceManagementPage(page).verify_kit_service_management_title() + BasePage(page).click_back_button() + + # Verify Kit Result Audit page opens as expected + FITTestKitsPage(page).go_to_kit_result_audit() + KitResultAuditPage(page).verify_kit_result_audit_title() + BasePage(page).click_back_button() + + # Verify View Algorithm page opens as expected + FITTestKitsPage(page).go_to_view_algorithm() + ViewAlgorithmsPage(page).verify_view_algorithms_body() + BasePage(page).click_back_button() + + # Verify View Screening Centre FIT page opens as expected + FITTestKitsPage(page).go_to_view_screening_centre_fit() + FITTestKitsPage( + page + ).sc_fit_configuration_page_screening_centre_dropdown.select_option( + general_properties["coventry_and_warwickshire_bcs_centre"] + ) + ViewScreeningCentreFITConfigurationPage( + page + ).verify_view_screening_centre_fit_title() + BasePage(page).click_back_button() # Go back to the Select Screening Centre page + BasePage(page).click_back_button() # Go back to the FIT Test Kits page + + # Verify Screening Incidents List page opens as expected + FITTestKitsPage(page).go_to_screening_incidents_list() + ScreeningIncidentsListPage(page).verify_screening_incidents_list_title() + BasePage(page).click_back_button() + + # Verify FIT QC Products page opens as expected + FITTestKitsPage(page).go_to_manage_qc_products() + ManageQCProductsPage(page).verify_manage_qc_products_title() + BasePage(page).click_back_button() + + # Verify Maintain Analysers page opens as expected + FITTestKitsPage(page).go_to_maintain_analysers() + MaintainAnalysersPage(page).verify_maintain_analysers_title() + BasePage(page).click_back_button() + FITTestKitsPage(page).verify_fit_test_kits_title() + + # Return to main menu + BasePage(page).click_main_menu_link() + BasePage(page).main_menu_header_is_displayed() diff --git a/tests/test_gfobt_test_kits_page.py b/tests/test_gfobt_test_kits_page.py new file mode 100644 index 00000000..6dd26985 --- /dev/null +++ b/tests/test_gfobt_test_kits_page.py @@ -0,0 +1,82 @@ +import pytest +from playwright.sync_api import Page + +from pages.base_page import BasePage +from pages.gfobt_test_kits.gfobt_test_kits_page import GFOBTTestKitsPage +from pages.gfobt_test_kits.gfobt_test_kit_logging_page import GFOBTTestKitLoggingPage +from pages.gfobt_test_kits.gfobt_test_kit_quality_control_reading_page import ( + GFOBTTestKitQualityControlReadingPage, +) +from pages.gfobt_test_kits.gfobt_view_test_kit_result_page import ViewTestKitResultPage +from pages.gfobt_test_kits.gfobt_create_qc_kit_page import ( + CreateQCKitPage, + ReadingDropdownOptions, +) +from utils.user_tools import UserTools + + +@pytest.fixture(scope="function", autouse=True) +def before_each(page: Page): + """ + Before every test is executed, this fixture logs in to BCSS as a test user and navigates to the + gfobt test kits page + """ + # Log in to BCSS + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + + # Go to gFOBT test kits page + BasePage(page).go_to_gfobt_test_kits_page() + + +@pytest.mark.smoke +def test_gfobt_test_kit_page_navigation(page: Page) -> None: + """ + Confirms all menu items are displayed on the gfobt test kits page, and that the relevant pages + are loaded when the links are clicked + """ + # Test kit logging page opens as expected + GFOBTTestKitsPage(page).go_to_test_kit_logging_page() + GFOBTTestKitLoggingPage(page).verify_test_kit_logging_title() + BasePage(page).click_back_button() + + # Test kit reading page opens as expected + GFOBTTestKitsPage(page).go_to_test_kit_reading_page() + GFOBTTestKitQualityControlReadingPage(page).verify_test_kit_logging_tile() + BasePage(page).click_back_button() + + # View test kit result page opens as expected + GFOBTTestKitsPage(page).go_to_test_kit_result_page() + ViewTestKitResultPage(page).verify_view_test_kit_result_title() + BasePage(page).click_back_button() + + # Create qc kit page opens as expected + GFOBTTestKitsPage(page).go_to_create_qc_kit_page() + CreateQCKitPage(page).verify_create_qc_kit_title() + BasePage(page).click_back_button() + + # Return to main menu + BasePage(page).click_main_menu_link() + BasePage(page).main_menu_header_is_displayed() + + +@pytest.mark.smoke +def test_create_a_qc_kit(page: Page) -> None: + """ + Confirms that a qc test kit can be created and that each of the dropdowns has an option set available for selection + """ + # Navigate to create QC kit page + GFOBTTestKitsPage(page).go_to_create_qc_kit_page() + + # Select QC kit drop down options + CreateQCKitPage(page).go_to_reading1dropdown(ReadingDropdownOptions.NEGATIVE.value) + CreateQCKitPage(page).go_to_reading2dropdown(ReadingDropdownOptions.POSITIVE.value) + CreateQCKitPage(page).go_to_reading3dropdown(ReadingDropdownOptions.POSITIVE.value) + CreateQCKitPage(page).go_to_reading4dropdown(ReadingDropdownOptions.UNUSED.value) + CreateQCKitPage(page).go_to_reading5dropdown(ReadingDropdownOptions.NEGATIVE.value) + CreateQCKitPage(page).go_to_reading6dropdown(ReadingDropdownOptions.POSITIVE.value) + + # Click save + CreateQCKitPage(page).go_to_save_kit() + + # Verify kit has saved + CreateQCKitPage(page).verify_kit_has_saved() diff --git a/tests/test_home_page_links.py b/tests/test_home_page_links.py new file mode 100644 index 00000000..5ee06d01 --- /dev/null +++ b/tests/test_home_page_links.py @@ -0,0 +1,76 @@ +import pytest +from playwright.sync_api import Page, expect +from utils.user_tools import UserTools +from pages.base_page import BasePage +from utils.date_time_utils import DateTimeUtils +import logging + + +@pytest.fixture(scope="function", autouse=True) +def before_each(page: Page): + """ + Before every test is executed, this fixture logs in to BCSS as a test user and results in the home page + being displayed + """ + # Log in to BCSS + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + + +@pytest.mark.smoke +def test_home_page_links_navigation(page: Page) -> None: + """ + Confirms that homepage links are visible and clickable, and the expected pages open when clicking the links + """ + homepage = BasePage(page) + + # Click 'show sub menu' link + homepage.click_sub_menu_link() + # Verify a sub menu is visible + expect(page.get_by_role("link", name="List All Sites")).to_be_visible() + + # Click 'hide sub menu' link + homepage.click_hide_sub_menu_link() + # Verify sub menu is hidden (alerts are visible) + expect(page.get_by_role("cell", name="Alerts", exact=True)).to_be_visible() + + # Click 'select org' link + homepage.click_select_org_link() + # Verify select org page is displayed + expect(page.locator("form")).to_contain_text("Choose an Organisation") + + # Click the 'back' link + homepage.click_back_button() + # Verify main menu is displayed + expect(page.get_by_role("cell", name="Alerts", exact=True)).to_be_visible() + + # Click release notes link + homepage.click_release_notes_link() + # Verify release notes are displayed + expect(page.locator("#page-title")).to_contain_text("Release Notes") + # Click the 'back' button + homepage.click_back_button() + + # Click the refresh alerts link + homepage.click_refresh_alerts_link() + # Verify that the 'last updated' timestamp matches the current date and time + ( + expect(page.locator('form[name="refreshCockpit"]')).to_contain_text( + "Refresh alerts (last updated :" + DateTimeUtils.current_datetime() + ) + ) + + # Click the user guide link + with page.expect_popup() as page1_info: + # Check the user guide link works + page.get_by_role("link", name="User guide").click() + # Check that the user guide page can be accessed + page1 = page1_info.value + logging.info(f"User Guide Page: {page1}") + + # Click 'help' link + with page.expect_popup() as page2_info: + # Check the help link works + page.get_by_role("link", name="Help").click() + # Check that the help page can be accessed + page2 = page2_info.value + logging.info(f"Help Page: {page2}") diff --git a/tests/test_login_to_bcss.py b/tests/test_login_to_bcss.py new file mode 100644 index 00000000..07788814 --- /dev/null +++ b/tests/test_login_to_bcss.py @@ -0,0 +1,16 @@ +import pytest +from playwright.sync_api import Page, expect +from utils.user_tools import UserTools + + +@pytest.mark.smoke +def test_successful_login_to_bcss(page: Page) -> None: + """ + Confirms that a user with valid credentials can log in to bcss + """ + # Enter a valid username and password and click 'sign in' button + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + # Confirm user has successfully signed in and is viewing the bcss homepage + expect(page.locator("#ntshAppTitle")).to_contain_text( + "Bowel Cancer Screening System" + ) diff --git a/tests/test_lynch_surveillance_page.py b/tests/test_lynch_surveillance_page.py new file mode 100644 index 00000000..47955be4 --- /dev/null +++ b/tests/test_lynch_surveillance_page.py @@ -0,0 +1,34 @@ +import pytest +from playwright.sync_api import Page +from pages.base_page import BasePage +from pages.lynch_surveillance.lynch_invitation_page import LynchInvitationPage +from pages.lynch_surveillance.set_lynch_invitation_rates_page import SetLynchInvitationRatesPage +from utils.user_tools import UserTools + + +@pytest.fixture(scope="function", autouse=True) +def before_each(page: Page): + """ + Before every test is executed, this fixture logs in to BCSS as a test user and navigates to the + lynch surveillance page + """ + # Log in to BCSS + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + + # Go to Lynch Surveillance page + BasePage(page).go_to_lynch_surveillance_page() + + +@pytest.mark.smoke +def test_lynch_surveillance_page_navigation(page: Page) -> None: + """ + Confirms that the 'set lynch invitation rates' link is visible and clickable, and navigates to the + expected page when clicked + """ + # 'Set lynch invitation rates' page loads as expected + LynchInvitationPage(page).click_set_lynch_invitation_rates_link() + SetLynchInvitationRatesPage(page).verify_set_lynch_invitation_rates_title() + + # Return to main menu + BasePage(page).click_main_menu_link() + BasePage(page).main_menu_header_is_displayed() diff --git a/tests/test_organisations_page.py b/tests/test_organisations_page.py new file mode 100644 index 00000000..ad462e88 --- /dev/null +++ b/tests/test_organisations_page.py @@ -0,0 +1,77 @@ +import pytest +from playwright.sync_api import Page, expect +from pages.base_page import BasePage +from pages.organisations.organisations_page import OrganisationsPage +from utils.user_tools import UserTools + + +@pytest.fixture(scope="function", autouse=True) +def before_each(page: Page): + """ + Before every test is executed, this fixture logs in to BCSS as a test user and navigates to the + organisations page + """ + # Log in to BCSS + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + + # Go to organisations page + BasePage(page).go_to_organisations_page() + + +@pytest.mark.smoke +def test_organisations_page_navigation(page: Page) -> None: + upload_nacs_data_bureau_link = page.get_by_text( + "Upload NACS data (Bureau)", exact=True + ) + bureau_page_link = page.get_by_text("Bureau", exact=True) + """ + Confirms all menu items are displayed on the organisations page, and that the relevant pages + are loaded when the links are clicked + """ + # Screening centre parameters page loads as expected + OrganisationsPage(page).go_to_screening_centre_parameters_page() + BasePage(page).bowel_cancer_screening_page_title_contains_text( + "Screening Centre Parameters" + ) + BasePage(page).click_back_button() + + # Organisation parameters page loads as expected + OrganisationsPage(page).go_to_organisation_parameters_page() + BasePage(page).bowel_cancer_screening_page_title_contains_text("System Parameters") + BasePage(page).click_back_button() + + # Organisation and site details page loads as expected + OrganisationsPage(page).go_to_organisations_and_site_details_page() + BasePage(page).bowel_cancer_screening_page_title_contains_text( + "Organisation and Site Details" + ) + BasePage(page).click_back_button() + + # The links below are visible (not clickable due to user role permissions) + expect(upload_nacs_data_bureau_link).to_be_visible() + expect(bureau_page_link).to_be_visible() + + # GP practice endorsement page loads as expected + OrganisationsPage(page).go_to_gp_practice_endorsement_page() + BasePage(page).bowel_cancer_screening_page_title_contains_text( + "GP Practice Endorsement" + ) + + # Return to main menu + BasePage(page).click_main_menu_link() + BasePage(page).bowel_cancer_screening_page_title_contains_text("Main Menu") + + +@pytest.mark.smoke +def test_view_an_organisations_system_parameters( + page: Page, general_properties: dict +) -> None: + """ + Confirms that an organisation's system parameters can be accessed and viewed + """ + # Go to screening centre parameters page + OrganisationsPage(page).go_to_screening_centre_parameters_page() + + # View an Organisation + page.get_by_role("link", name=general_properties["screening_centre_code"]).click() + BasePage(page).bowel_cancer_screening_page_title_contains_text("System Parameters") diff --git a/tests/test_reports_page.py b/tests/test_reports_page.py new file mode 100644 index 00000000..8fa61063 --- /dev/null +++ b/tests/test_reports_page.py @@ -0,0 +1,519 @@ +import pytest +from playwright.sync_api import Page, expect +from pages.base_page import BasePage +from pages.reports.reports_page import ReportsPage +from utils.date_time_utils import DateTimeUtils +from utils.user_tools import UserTools + + +@pytest.fixture(scope="function", autouse=True) +def before_each(page: Page): + """ + Before every test is executed, this fixture logs in to BCSS as a test user and navigates to the + reports page + """ + # Log in to BCSS + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + + # Open reports page + BasePage(page).go_to_reports_page() + + +@pytest.mark.smoke +def test_reports_page_navigation(page: Page) -> None: + """ + Confirms all menu items are displayed on the reports page, and that the relevant pages + are loaded when the links are clicked + """ + + # Bureau reports link is visible + expect(ReportsPage(page).bureau_reports_link).to_be_visible() + + # Failsafe reports page opens as expected + ReportsPage(page).go_to_failsafe_reports_page() + BasePage(page).bowel_cancer_screening_page_title_contains_text("Failsafe Reports") + BasePage(page).click_back_button() + + # Operational reports page opens as expected + ReportsPage(page).go_to_operational_reports_page() + BasePage(page).bowel_cancer_screening_page_title_contains_text( + "Operational Reports" + ) + BasePage(page).click_back_button() + + # Strategic reports page opens as expected + ReportsPage(page).go_to_strategic_reports_page() + BasePage(page).bowel_cancer_screening_page_title_contains_text("Strategic Reports") + BasePage(page).click_back_button() + + # "Cancer waiting times reports" page opens as expected + ReportsPage(page).go_to_cancer_waiting_times_reports_page() + BasePage(page).bowel_cancer_screening_page_title_contains_text( + "Cancer Waiting Times Reports" + ) + BasePage(page).click_back_button() + + # Dashboard opens as expected TODO - this step may be failing legitimately + # ReportsPage(page).go_to_dashboard() + # BasePage(page).bowel_cancer_screening_page_title_contains_text("Dashboard") + # BasePage(page).click_back_button() + + # QA Report : Dataset Completion link is visible + expect(ReportsPage(page).qa_report_dataset_completion_link).to_be_visible() + + # Return to main menu + BasePage(page).click_main_menu_link() + BasePage(page).bowel_cancer_screening_page_title_contains_text("Main Menu") + + +@pytest.mark.smoke +# Failsafe Reports +def test_failsafe_reports_date_report_last_requested(page: Page) -> None: + """ + Confirms 'date_report_last_requested' page loads, 'generate report' and 'refresh' buttons work as expected + and the timestamp updates to current date and time when refreshed + """ + + # Go to failsafe reports page + ReportsPage(page).go_to_failsafe_reports_page() + + # Click 'date report last requested' link + ReportsPage(page).go_to_date_report_last_requested_page() + + # Verify 'Date Report Last Requested' is the page title + BasePage(page).bowel_cancer_screening_page_title_contains_text( + "Date Report Last Requested" + ) + + # Click 'generate report' button + ReportsPage(page).click_generate_report_button() + # Verify timestamp has updated (equals current date and time) + report_timestamp = DateTimeUtils.report_timestamp_date_format() + expect(ReportsPage(page).common_report_timestamp_element).to_contain_text( + report_timestamp + ) + + # Click 'refresh' button + ReportsPage(page).click_refresh_button() + + # Verify timestamp has updated (equals current date and time) + report_timestamp = DateTimeUtils.report_timestamp_date_format() + expect(ReportsPage(page).common_report_timestamp_element).to_contain_text( + report_timestamp + ) + + +@pytest.mark.smoke +def test_failsafe_reports_screening_subjects_with_inactive_open_episode( + page: Page, +) -> None: + """ + Confirms 'screening_subjects_with_inactive_open_episode' page loads, 'generate report' button works as expected + and that a screening subject record can be opened + """ + + # Go to failsafe reports page + ReportsPage(page).go_to_failsafe_reports_page() + + # Click screening subjects with inactive open episode link + ReportsPage(page).go_to_screening_subjects_with_inactive_open_episode_page() + + # Verify "Screening Subjects With Inactive Open Episode" is the page title + BasePage(page).bowel_cancer_screening_page_title_contains_text( + "Screening Subjects With Inactive Open Episode" + ) + + # Click 'Generate Report' button + ReportsPage(page).click_generate_report_button() + + # Open a screening subject record + ReportsPage( + page + ).click_fail_safe_reports_screening_subjects_with_inactive_open_episodes_link() + + # Verify the page title is "Subject Screening Summary" + BasePage(page).bowel_cancer_screening_page_title_contains_text( + "Subject Screening Summary" + ) + + +@pytest.mark.smoke +def test_failsafe_reports_subjects_ceased_due_to_date_of_birth_changes( + page: Page, +) -> None: + """ + Confirms 'subjects_ceased_due_to_date_of_birth_changes' page loads, + the datepicker and 'generate report' button works as expected + the timestamp updates to current date and time when refreshed and + a screening subject record can be opened + """ + + # Test Data + report_start_date = "18/03/2023" # This date is specific to this test only + + # Go to failsafe reports page + ReportsPage(page).go_to_failsafe_reports_page() + + # Click on "Subjects Ceased Due to Date Of Birth Changes" link + ReportsPage(page).go_to_subjects_ceased_due_to_date_of_birth_changes_page() + + # Select a "report start date" from the calendar + ReportsPage(page).report_start_date_field.fill(report_start_date) + + # Click "Generate Report" + ReportsPage(page).click_generate_report_button() + # Verify timestamp has updated to current date and time + report_timestamp = DateTimeUtils.report_timestamp_date_format() + expect(ReportsPage(page).subject_ceased_report_timestamp_element).to_contain_text( + report_timestamp + ) + + # Open a screening subject record from the search results + + ReportsPage(page).click_failsafe_reports_sub_links() + + # Verify page title is "Subject Demographic" + BasePage(page).bowel_cancer_screening_page_title_contains_text( + "Subject Demographic" + ) + + +@pytest.mark.smoke +def test_failsafe_reports_allocate_sc_for_patient_movements_within_hub_boundaries( + page: Page, general_properties: dict +) -> None: + """ + Confirms 'allocate_sc_for_patient_movements_within_hub_boundaries' page loads, + the 'generate report' button works as expected + the timestamp updates to current date and time when refreshed + a screening subject record can be opened and + a different SC can be allocated to a patient record + """ + + # Go to failsafe reports page + failsafe_report_page = ReportsPage(page) + failsafe_report_page.go_to_failsafe_reports_page() + + # Click on the "Allocate SC for Patient Movements within Hub Boundaries" link + failsafe_report_page.go_to_allocate_sc_for_patient_movements_within_hub_boundaries_page() + + # Verify page title is "Allocate SC for Patient Movements within Hub Boundaries" + failsafe_report_page.bowel_cancer_screening_page_title_contains_text( + "Allocate SC for Patient Movements within Hub Boundaries" + ) + + # Click "Generate Report" + failsafe_report_page.click_generate_report_button() + + # Verify timestamp has updated to current date and time + report_timestamp = DateTimeUtils.report_timestamp_date_format() + expect(ReportsPage(page).common_report_timestamp_element).to_contain_text( + report_timestamp + ) + + # Open a screening subject record from the first row/first cell of the table + # nhs_number_link.click() + ReportsPage(page).click_failsafe_reports_sub_links() + + # Verify page title is "Set Patient's Screening Centre" + BasePage(page).bowel_cancer_screening_page_title_contains_text( + "Set Patient's Screening Centre" + ) + + # Select another screening centre + ReportsPage(page).set_patients_screening_centre_dropdown.select_option( + general_properties["coventry_and_warwickshire_bcs_centre"] + ) + + # Click update + failsafe_report_page.click_reports_pages_update_button() + + # Verify new screening centre has saved + expect(ReportsPage(page).set_patients_screening_centre_dropdown).to_have_value( + general_properties["coventry_and_warwickshire_bcs_centre"] + ) + + +@pytest.mark.smoke +def test_failsafe_reports_allocate_sc_for_patient_movements_into_your_hub( + page: Page, +) -> None: + """ + Confirms 'allocate_sc_for_patient_movements_into_your_hub' page loads, + the 'generate report' and 'refresh' buttons work as expected and + the timestamp updates to current date and time when refreshed + """ + + # Go to failsafe reports page + ReportsPage(page).go_to_failsafe_reports_page() + + # Click on "allocate sc for patient movements into your hub" link + ReportsPage(page).go_to_allocate_sc_for_patient_movements_into_your_hub_page() + + # Verify page title is "Date Report Last Requested" + BasePage(page).bowel_cancer_screening_page_title_contains_text( + "Allocate SC for Patient Movements into your Hub" + ) + + # Click "Generate Report" button + ReportsPage(page).click_generate_report_button() + + # Verify timestamp has updated to current date and time + report_timestamp = DateTimeUtils.report_timestamp_date_format() + expect(ReportsPage(page).common_report_timestamp_element).to_contain_text( + report_timestamp + ) + + # Click "Refresh" button + ReportsPage(page).click_refresh_button() + + # Verify timestamp has updated to current date and time + report_timestamp = DateTimeUtils.report_timestamp_date_format() + expect(ReportsPage(page).common_report_timestamp_element).to_contain_text( + report_timestamp + ) + + +@pytest.mark.smoke +def test_failsafe_reports_identify_and_link_new_gp(page: Page) -> None: + """ + Confirms 'identify_and_link_new_gp' page loads, + the 'generate report' and 'refresh' buttons work as expected + the timestamp updates to current date and time when refreshed + a screening subject record can be opened and the Link GP practice to Screening Centre page + can be opened from here + """ + + # Go to failsafe reports page + ReportsPage(page).go_to_failsafe_reports_page() + + # Click on "Identify and link new GP" link + ReportsPage(page).go_to_identify_and_link_new_gp_page() + + # Verify page title is "Identify and link new GP practices" + BasePage(page).bowel_cancer_screening_page_title_contains_text( + "Identify and link new GP practices" + ) + + # Click on "Generate Report" + ReportsPage(page).click_generate_report_button() + + # Verify timestamp has updated to current date and time + report_timestamp = DateTimeUtils.report_timestamp_date_format() + expect(ReportsPage(page).common_report_timestamp_element).to_contain_text( + report_timestamp + ) + + # Click "Refresh" button + ReportsPage(page).click_refresh_button() + + # Verify timestamp has updated to current date and time + report_timestamp = DateTimeUtils.report_timestamp_date_format() + expect(ReportsPage(page).common_report_timestamp_element).to_contain_text( + report_timestamp + ) + + # Open a practice code from the first row/second cell of the table + ReportsPage(page).click_fail_safe_reports_identify_and_link_new_gp_practices_link() + + # Verify page title is "Link GP practice to Screening Centre" + BasePage(page).bowel_cancer_screening_page_title_contains_text( + "Link GP practice to Screening Centre" + ) + + +@pytest.mark.smoke +# Operational Reports + + +def test_operational_reports_appointment_attendance_not_updated( + page: Page, general_properties: dict +) -> None: + """ + Confirms 'appointment_attendance_not_updated' page loads, + a SC can be selected from the dropdown + the 'generate report' button works as expected + the timestamp updates to current date and time when refreshed and + an appointment record can be opened from here + """ + + # Go to operational reports page + ReportsPage(page).go_to_operational_reports_page() + + # Go to "appointment attendance not updated" report page + ReportsPage(page).go_to_appointment_attendance_not_updated_page() + + # Verify page title is "Appointment Attendance Not Updated" + BasePage(page).bowel_cancer_screening_page_title_contains_text( + "Appointment Attendance Not Updated" + ) + + # Select a screening centre from the drop-down options + ReportsPage( + page + ).attendance_not_updated_set_patients_screening_centre_dropdown.select_option( + general_properties["coventry_and_warwickshire_bcs_centre"] + ) + + # Click "Generate Report" button + ReportsPage(page).click_generate_report_button() + + # Verify timestamp has updated to current date and time + report_timestamp = DateTimeUtils.report_timestamp_date_format() + expect(ReportsPage(page).common_report_timestamp_element).to_contain_text( + report_timestamp + ) + + # Open an appointment record from the report + ReportsPage(page).click_failsafe_reports_sub_links() + + # Verify the page title is "Appointment Detail" + BasePage(page).bowel_cancer_screening_page_title_contains_text("Appointment Detail") + + +@pytest.mark.smoke +def test_operational_reports_fobt_kits_logged_but_not_read(page: Page) -> None: + """ + Confirms 'fobt_kits_logged_but_not_read' page loads, + the 'refresh' button works as expected and + the timestamp updates to current date and time when refreshed + """ + + # Go to operational reports page + ReportsPage(page).go_to_operational_reports_page() + + # Go to "FOBT Kits Logged but Not Read" page + ReportsPage(page).go_to_fobt_kits_logged_but_not_read_page() + + # Verify page title is "FOBT Kits Logged but Not Read - Summary View" + BasePage(page).bowel_cancer_screening_page_title_contains_text( + "FOBT Kits Logged but Not Read - Summary View" + ) + + # Click refresh button + ReportsPage(page).click_refresh_button() + + # Verify timestamp has updated to current date and time + report_timestamp = ( + DateTimeUtils.fobt_kits_logged_but_not_read_report_timestamp_date_format() + ) + expect( + ReportsPage(page).fobt_logged_not_read_report_timestamp_element + ).to_contain_text(f"Report generated on {report_timestamp}.") + + +@pytest.mark.smoke +def test_operational_reports_demographic_update_inconsistent_with_manual_update( + page: Page, +) -> None: + """ + Confirms 'demographic_update_inconsistent_with_manual_update' page loads, + the 'refresh' button works as expected and + the timestamp updates to current date and time when refreshed + """ + # Go to operational reports page + ReportsPage(page).go_to_operational_reports_page() + + # Go to "Demographic Update Inconsistent With Manual Update" page + ReportsPage(page).go_to_demographic_update_inconsistent_with_manual_update_page() + + # Verify page title is "Demographic Update Inconsistent With Manual Update" + BasePage(page).bowel_cancer_screening_page_title_contains_text( + "Demographic Update Inconsistent With Manual Update" + ) + + +@pytest.mark.smoke +def test_operational_reports_screening_practitioner_6_weeks_availability_not_set_up( + page: Page, general_properties: dict +) -> None: + """ + Confirms 'screening_practitioner_6_weeks_availability_not_set_up_report' page loads, + a SC can be selected + the 'generate report' and 'refresh' buttons work as expected and + the timestamp updates to current date and time when refreshed + """ + + # Go to operational reports page + ReportsPage(page).go_to_operational_reports_page() + + # Go to "Screening Practitioner 6 Weeks Availability Not Set Up" page + ReportsPage( + page + ).go_to_screening_practitioner_6_weeks_availability_not_set_up_report_page() + + # Verify page title is "Screening Practitioner 6 Weeks Availability Not Set Up" + BasePage(page).bowel_cancer_screening_page_title_contains_text( + "Screening Practitioner 6 Weeks Availability Not Set Up" + ) + + # Select a screening centre + ReportsPage( + page + ).six_weeks_availability_not_set_up_set_patients_screening_centre_dropdown.select_option( + general_properties["coventry_and_warwickshire_bcs_centre"] + ) + + # Click "Generate Report" + ReportsPage(page).click_generate_report_button() + + # Verify timestamp has updated to current date and time + report_timestamp = DateTimeUtils.report_timestamp_date_format() + expect( + ReportsPage(page).six_weeks_availability_not_set_up_report_timestamp_element + ).to_contain_text(report_timestamp) + + # Click "Refresh" button + ReportsPage(page).click_refresh_button() + + # Verify timestamp has updated to current date and time + report_timestamp = DateTimeUtils.report_timestamp_date_format() + expect( + ReportsPage(page).six_weeks_availability_not_set_up_report_timestamp_element + ).to_contain_text(report_timestamp) + + +@pytest.mark.smoke +def test_operational_reports_screening_practitioner_appointments( + page: Page, general_properties: dict +) -> None: + """ + Confirms 'screening_practitioner_appointments' page loads, + a SC and Screening Practitioner can be selected + the 'generate report' button works as expected and + the timestamp updates to current date and time when refreshed + """ + + # Go to operational reports page + ReportsPage(page).go_to_operational_reports_page() + + # Go to "Screening Practitioner Appointments" page + ReportsPage(page).go_to_screening_practitioner_appointments_page() + + # Verify page title is "Screening Practitioner Appointments" + BasePage(page).bowel_cancer_screening_page_title_contains_text( + "Screening Practitioner Appointments" + ) + + # Select a screening centre + ReportsPage( + page + ).practitioner_appointments_set_patients_screening_centre_dropdown.select_option( + general_properties["coventry_and_warwickshire_bcs_centre"] + ) + + # Select a screening practitioner + ReportsPage(page).screening_practitioner_dropdown.select_option( + general_properties["screening_practitioner_named_another_stubble"] + ) + + # Click "Generate Report" + ReportsPage(page).operational_reports_sp_appointments_generate_report_button.click() + + # Verify timestamp has updated to current date and time + report_timestamp = ( + DateTimeUtils.screening_practitioner_appointments_report_timestamp_date_format() + ) + expect(ReportsPage(page).common_report_timestamp_element).to_contain_text( + report_timestamp + ) diff --git a/tests/test_screening_practitioner_appointments_page.py b/tests/test_screening_practitioner_appointments_page.py new file mode 100644 index 00000000..d60e3fd5 --- /dev/null +++ b/tests/test_screening_practitioner_appointments_page.py @@ -0,0 +1,63 @@ +import pytest +from playwright.sync_api import Page, expect +from pages.base_page import BasePage +from pages.screening_practitioner_appointments.screening_practitioner_appointments_page import ( + ScreeningPractitionerAppointmentsPage, +) +from pages.bowel_scope.bowel_scope_appointments_page import BowelScopeAppointmentsPage +from pages.screening_practitioner_appointments.colonoscopy_assessment_appointments_page import ( + ColonoscopyAssessmentAppointmentsPage, +) +from utils.user_tools import UserTools + + +@pytest.fixture(scope="function", autouse=True) +def before_each(page: Page): + """ + Before every test is executed, this fixture logs in to BCSS as a test user and navigates to the + screening_practitioner_appointments page + """ + # Log in to BCSS + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + + # Go to screening practitioner appointments page + BasePage(page).go_to_screening_practitioner_appointments_page() + + +@pytest.mark.smoke +def test_screening_practitioner_appointments_page_navigation(page: Page) -> None: + """ + Confirms screening_practitioner_appointments page loads and the expected links are visible + and clickable (where the user has required permissions). + """ + # Verify View appointments page opens as expected + ScreeningPractitionerAppointmentsPage(page).go_to_view_appointments_page() + BowelScopeAppointmentsPage(page).verify_page_title() + BowelScopeAppointmentsPage(page).click_back_button() + + # Verify Patients that Require Colonoscopy Assessment Appointments page opens as expected + ScreeningPractitionerAppointmentsPage(page).go_to_patients_that_require_page() + ColonoscopyAssessmentAppointmentsPage(page).verify_page_header() + + ColonoscopyAssessmentAppointmentsPage(page).click_back_button() + + expect( + ScreeningPractitionerAppointmentsPage( + page + ).patients_that_require_colonoscopy_assessment_appointments_bowel_scope_link + ).to_be_visible() + expect( + ScreeningPractitionerAppointmentsPage( + page + ).patients_that_require_surveillance_appointment_link + ).to_be_visible() + expect( + ScreeningPractitionerAppointmentsPage(page).patients_that_require_post + ).to_be_visible() + expect( + ScreeningPractitionerAppointmentsPage(page).set_availability_link + ).to_be_visible() + + # Return to main menu + ScreeningPractitionerAppointmentsPage(page).click_main_menu_link() + ScreeningPractitionerAppointmentsPage(page).main_menu_header_is_displayed() diff --git a/tests/test_screening_subject_search_page.py b/tests/test_screening_subject_search_page.py new file mode 100644 index 00000000..630b6d2b --- /dev/null +++ b/tests/test_screening_subject_search_page.py @@ -0,0 +1,507 @@ +import pytest +from playwright.sync_api import Page +from pages.base_page import BasePage +from pages.screening_subject_search.subject_screening_search_page import ( + ScreeningStatusSearchOptions, + LatestEpisodeStatusSearchOptions, + SearchAreaSearchOptions, +) +from pages.screening_subject_search.subject_screening_summary_page import ( + SubjectScreeningSummaryPage, +) +from utils.screening_subject_page_searcher import ( + search_subject_by_nhs_number, + search_subject_by_surname, + search_subject_by_forename, + search_subject_by_dob, + search_subject_by_postcode, + search_subject_by_episode_closed_date, + check_clear_filters_button_works, + search_subject_by_status, + search_subject_by_latest_event_status, + search_subject_by_search_area, +) +from utils.user_tools import UserTools + + +@pytest.fixture(scope="function", autouse=True) +def before_each(page: Page): + """ + Before every test is executed, this fixture logs in to BCSS as a test user and navigates to the + screening_subject_search page + """ + # Log in to BCSS + UserTools.user_login(page, "Hub Manager State Registered at BCS01") + + # Go to screening subject search page + BasePage(page).go_to_screening_subject_search_page() + + +@pytest.mark.smoke +def test_search_screening_subject_by_nhs_number( + page: Page, general_properties: dict +) -> None: + """ + Confirms a screening subject can be searched for, using their nhs number by doing the following: + - Clear filters (if any filters have persisted the NHS number field is inactive) + - Enter an NHS number + - Press Tab (required after text input, to make the search button become active). + - Click search button + - Verify the Subject Screening Summary page is displayed + """ + search_subject_by_nhs_number(page, general_properties["nhs_number"]) + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_screening_summary() + + +@pytest.mark.smoke +def test_search_screening_subject_by_surname( + page: Page, general_properties: dict +) -> None: + """ + Confirms a screening subject can be searched for, using their surname by doing the following: + - Clear filters + - Enter a surname + - Press Tab (required after text input, to make the search button become active). + - Click search button + - Verify the subject summary page is displayed + """ + search_subject_by_surname(page, general_properties["surname"]) + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_screening_summary() + + +@pytest.mark.smoke +def test_search_screening_subject_by_forename( + page: Page, general_properties: dict +) -> None: + """ + Confirms a screening subject can be searched for, using their forename by doing the following: + - Clear filters + - Enter a forename + - Press Tab (required after text input, to make the search button become active). + - Click search button + - Verify the subject summary page is displayed + """ + search_subject_by_forename(page, general_properties["forename"]) + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_screening_summary() + + +@pytest.mark.smoke +def test_search_screening_subject_by_dob(page: Page, general_properties: dict) -> None: + """ + Confirms a screening subject can be searched for, using their date of birth by doing the following: + - Clear filters + - Enter a date in the dob field + - Press Tab (required after text input, to make the search button become active). + - Click search button + - Verify the subject search results page is displayed + """ + search_subject_by_dob(page, general_properties["subject_dob"]) + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_search_results() + + +@pytest.mark.smoke +def test_search_screening_subject_by_postcode(page: Page) -> None: + """ + Confirms a screening subject can be searched for, using their postcode by doing the following: + - Clear filters + - Enter a postcode + - Press Tab (required after text input, to make the search button become active). + - Click search button + - Verify the subject search results page is displayed + """ + search_subject_by_postcode(page, "*") + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_search_results() + + +@pytest.mark.smoke +def test_search_screening_subject_by_episode_closed_date( + page: Page, general_properties: dict +) -> None: + """ + Confirms a screening subject can be searched for, using their episode closed date by doing the following: + - Clear filters + - Enter an "episode closed date" + - Press Tab (required after text input, to make the search button become active). + - Click search button + - Verify the subject search results page is displayed + - Verify the results contain the date that was searched for + """ + search_subject_by_episode_closed_date( + page, general_properties["episode_closed_date"] + ) + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_search_results() + SubjectScreeningSummaryPage(page).verify_result_contains_text( + general_properties["episode_closed_date"] + ) + + +@pytest.mark.smoke +def test_search_criteria_clear_filters_button( + page: Page, general_properties: dict +) -> None: + """ + Confirms the 'clear filters' button on the search page works as expected by doing the following: + - Enter number in NHS field and verify value + - Click clear filters button and verify field is empty + """ + check_clear_filters_button_works(page, general_properties["nhs_number"]) + + +@pytest.mark.smoke +# Tests searching via the "Screening Status" drop down list +def test_search_screening_subject_by_status_call(page: Page) -> None: + """ + Confirms screening subjects can be searched for, using the screening status (call) by doing the following: + - Clear filters + - Select status from dropdown + - Pressing Tab is required after text input, to make the search button become active. + - Click search button + - Verify the subject search results page is displayed + """ + search_subject_by_status(page, ScreeningStatusSearchOptions.CALL_STATUS.value) + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_search_results() + + +@pytest.mark.smoke +def test_search_screening_subject_by_status_inactive(page: Page) -> None: + """ + Confirms screening subjects can be searched for, using the screening status (call) by doing the following: + - Clear filters + - Select status from dropdown + - Pressing Tab is required after text input, to make the search button become active. + - Click search button + - Verify the subject search results page is displayed + """ + search_subject_by_status(page, ScreeningStatusSearchOptions.INACTIVE_STATUS.value) + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_search_results() + + +@pytest.mark.smoke +def test_search_screening_subject_by_status_opt_in(page: Page) -> None: + """ + Confirms screening subjects can be searched for, using the screening status (call) by doing the following: + - Clear filters + - Select status from dropdown + - Pressing Tab is required after text input, to make the search button become active. + - Click search button + - Verify the subject search results page is displayed + """ + search_subject_by_status(page, ScreeningStatusSearchOptions.OPT_IN_STATUS.value) + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_search_results() + + +@pytest.mark.smoke +def test_search_screening_subject_by_status_recall(page: Page) -> None: + """ + Confirms screening subjects can be searched for, using the screening status (call) by doing the following: + - Clear filters + - Select status from dropdown + - Pressing Tab is required after text input, to make the search button become active. + - Click search button + - Verify the subject search results page is displayed + """ + search_subject_by_status(page, ScreeningStatusSearchOptions.RECALL_STATUS.value) + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_search_results() + + +@pytest.mark.smoke +def test_search_screening_subject_by_status_self_referral(page: Page) -> None: + """ + Confirms screening subjects can be searched for, using the screening status (call) by doing the following: + - Clear filters + - Select status from dropdown + - Pressing Tab is required after text input, to make the search button become active. + - Click search button + - Verify the subject search results page is displayed + """ + search_subject_by_status( + page, ScreeningStatusSearchOptions.SELF_REFERRAL_STATUS.value + ) + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_search_results() + + +@pytest.mark.smoke +def test_search_screening_subject_by_status_surveillance(page: Page) -> None: + """ + Confirms screening subjects can be searched for, using the screening status (call) by doing the following: + - Clear filters + - Select status from dropdown + - Pressing Tab is required after text input, to make the search button become active. + - Click search button + - Verify the subject search results page is displayed + """ + search_subject_by_status( + page, ScreeningStatusSearchOptions.SURVEILLANCE_STATUS.value + ) + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_search_results() + + +@pytest.mark.smoke +def test_search_screening_subject_by_status_seeking_further_data(page: Page) -> None: + """ + Confirms screening subjects can be searched for, using the screening status (call) by doing the following: + - Clear filters + - Select status from dropdown + - Pressing Tab is required after text input, to make the search button become active. + - Click search button + - Verify the subject search results page is displayed + """ + search_subject_by_status( + page, ScreeningStatusSearchOptions.SEEKING_FURTHER_DATA_STATUS.value + ) + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_search_results() + + +@pytest.mark.smoke +def test_search_screening_subject_by_status_ceased(page: Page) -> None: + """ + Confirms screening subjects can be searched for, using the screening status (call) by doing the following: + - Clear filters + - Select status from dropdown + - Pressing Tab is required after text input, to make the search button become active. + - Click search button + - Verify the subject search results page is displayed + """ + search_subject_by_status(page, ScreeningStatusSearchOptions.CEASED_STATUS.value) + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_search_results() + + +@pytest.mark.smoke +def test_search_screening_subject_by_status_lynch_surveillance(page: Page) -> None: + """ + Confirms screening subjects can be searched for, using the screening status (call) by doing the following: + - Clear filters + - Select status from dropdown + - Pressing Tab is required after text input, to make the search button become active. + - Click search button + - Verify the subject search results page is displayed + """ + search_subject_by_status( + page, ScreeningStatusSearchOptions.LYNCH_SURVEILLANCE_STATUS.value + ) + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_search_results() + + +@pytest.mark.smoke +def test_search_screening_subject_by_status_lynch_self_referral(page: Page) -> None: + """ + Confirms screening subjects can be searched for, using the screening status (call) by doing the following: + - Clear filters + - Select status from dropdown + - Pressing Tab is required after text input, to make the search button become active. + - Click search button + - Verify the subject search results page is displayed + """ + search_subject_by_status( + page, ScreeningStatusSearchOptions.LYNCH_SELF_REFERRAL_STATUS.value + ) + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_screening_summary() + + +@pytest.mark.smoke +# search_subject_by_latest_event_status +def test_search_screening_subject_by_latest_episode_status_open_paused( + page: Page, +) -> None: + """ + Confirms screening subjects can be searched for, using the screening status (call) by doing the following: + - Clear filters + - Select status from dropdown + - Pressing Tab is required after text input, to make the search button become active. + - Click search button + - Verify the subject search results page is displayed + """ + search_subject_by_latest_event_status( + page, LatestEpisodeStatusSearchOptions.OPEN_PAUSED_STATUS.value + ) + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_search_results() + + +@pytest.mark.smoke +def test_search_screening_subject_by_latest_episode_status_closed(page: Page) -> None: + """ + Confirms screening subjects can be searched for, using the screening status (call) by doing the following: + - Clear filters + - Select status from dropdown + - Pressing Tab is required after text input, to make the search button become active. + - Click search button + - Verify the subject search results page is displayed + """ + search_subject_by_latest_event_status( + page, LatestEpisodeStatusSearchOptions.CLOSED_STATUS.value + ) + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_search_results() + + +@pytest.mark.smoke +def test_search_screening_subject_by_latest_episode_status_no_episode( + page: Page, +) -> None: + """ + Confirms screening subjects can be searched for, using the screening status (call) by doing the following: + - Clear filters + - Select status from dropdown + - Pressing Tab is required after text input, to make the search button become active. + - Click search button + - Verify the subject search results page is displayed + """ + search_subject_by_latest_event_status( + page, LatestEpisodeStatusSearchOptions.NO_EPISODE_STATUS.value + ) + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_search_results() + + +@pytest.mark.smoke +# Tests searching via the "Search Area" drop down list +def test_search_screening_subject_by_home_hub(page: Page) -> None: + """ + Confirms screening subjects can be searched for, using the search area (home hub) by doing the following: + - Clear filters + - Select screening status "recall" (searching by search area requires another search option to be selected) + - Select "Home Hub" option from dropdown + - Click search button + - Verify search results are displayed + """ + search_subject_by_search_area( + page, + ScreeningStatusSearchOptions.RECALL_STATUS.value, + SearchAreaSearchOptions.SEARCH_AREA_HOME_HUB.value, + ) + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_search_results() + + +@pytest.mark.smoke +def test_search_screening_subject_by_gp_practice( + page: Page, general_properties: dict +) -> None: + """ + Confirms screening subjects can be searched for, using the search area (home hub) by doing the following: + - Clear filters + - Select screening status "recall" (searching by search area requires another search option to be selected) + - Select "GP Practice" option from dropdown + - Enter GP practice code + - Click search button + - Verify search results are displayed + # Verify springs health centre is visible in search results + """ + search_subject_by_search_area( + page, + ScreeningStatusSearchOptions.CALL_STATUS.value, + SearchAreaSearchOptions.SEARCH_AREA_GP_PRACTICE.value, + general_properties["gp_practice_code"], + ) + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_search_results() + SubjectScreeningSummaryPage(page).verify_result_contains_text( + "SPRINGS HEALTH CENTRE" + ) + + +@pytest.mark.smoke +def test_search_screening_subject_by_ccg(page: Page, general_properties: dict) -> None: + """ + Confirms screening subjects can be searched for, using the search area (ccg) by doing the following: + - Clear filters + - Select screening status "call" (searching by search area requires another search option to be selected) + - Select "CCG" from dropdown + - Enter CCG code + - Enter GP practice code + - Click search button + - Verify search results are displayed + """ + search_subject_by_search_area( + page, + ScreeningStatusSearchOptions.CALL_STATUS.value, + SearchAreaSearchOptions.SEARCH_AREA_CCG.value, + general_properties["ccg_code"], + general_properties["gp_practice_code"], + ) + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_search_results() + + +@pytest.mark.smoke +def test_search_screening_subject_by_screening_centre( + page: Page, general_properties: dict +) -> None: + """ + Confirms screening subjects can be searched for, using the search area (screening centre) by doing the following: + - Clear filters + - Select screening status "call" (searching by search area requires another search option to be selected) + - Select "Screening Centre" option from dropdown + - Enter a screening centre code + - Click search button + - Verify search results are displayed + """ + search_subject_by_search_area( + page, + ScreeningStatusSearchOptions.CALL_STATUS.value, + SearchAreaSearchOptions.SEARCH_AREA_SCREENING_CENTRE.value, + general_properties["screening_centre_code"], + ) + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_search_results() + + +@pytest.mark.vpn_required +@pytest.mark.smoke +def test_search_screening_subject_by_whole_database(page: Page) -> None: + """ + Confirms screening subjects can be searched for, using the search area (whole database) by doing the following: + - Clear filters + - Select screening status "recall" (searching by search area requires another search option to be selected) + - Select "whole database" option from dropdown + - Click search button + - Verify search results are displayed" + """ + search_subject_by_search_area( + page, + ScreeningStatusSearchOptions.RECALL_STATUS.value, + SearchAreaSearchOptions.SEARCH_AREA_WHOLE_DATABASE.value, + ) + SubjectScreeningSummaryPage( + page + ).verify_subject_search_results_title_subject_search_results() diff --git a/tests_utils/query_builder_test_harness/mock_selection_builder.py b/tests_utils/query_builder_test_harness/mock_selection_builder.py new file mode 100644 index 00000000..2b15a829 --- /dev/null +++ b/tests_utils/query_builder_test_harness/mock_selection_builder.py @@ -0,0 +1,159 @@ +# mock_selection_builder.py — Development-only testing harness for criteria logic +import sys +import os +import re + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) +from classes.selection_builder_exception import SelectionBuilderException +from classes.subject_selection_criteria_key import SubjectSelectionCriteriaKey + +# ------------------------------------------------------------------------ +# 🧰 Stubbed Data Classes (for symbolic mapping or subject context) +# ------------------------------------------------------------------------ + + +class Subject: + def __init__(self, lynch_due_date_change_reason_id): + self.lynch_due_date_change_reason_id = lynch_due_date_change_reason_id + + +# ------------------------------------------------------------------------ +# 🧪 Enum-Like Mocks (for symbolic value resolution) +# ------------------------------------------------------------------------ + + +class NotifyEventStatus: + _label_to_id = { + "S1": 9901, + "S2": 9902, + "M1": 9903, + # Extend as needed + } + + @classmethod + def get_id(cls, description: str) -> int: + key = description.strip().upper() + if key not in cls._label_to_id: + raise ValueError(f"Unknown Notify event type: '{description}'") + return cls._label_to_id[key] + + +class YesNoType: + YES = "yes" + NO = "no" + + _valid = {YES, NO} + + @classmethod + def from_description(cls, description: str) -> str: + key = description.strip().lower() + if key not in cls._valid: + raise ValueError(f"Expected 'yes' or 'no', got: '{description}'") + return key + + +# ------------------------------------------------------------------------ +# 🧠 Utility Functions (reused parsing helpers) +# ------------------------------------------------------------------------ + + +def parse_notify_criteria(criteria: str) -> dict: + """ + Parses criteria strings like 'S1 - new' or 'S1 (S1w) - sending' into parts. + """ + criteria = criteria.strip() + if criteria.lower() == "none": + return {"status": "none"} + + pattern = r"^(?P[^\s(]+)(?:\s+\((?P[^)]+)\))?\s*-\s*(?P\w+)$" + match = re.match(pattern, criteria, re.IGNORECASE) + if not match: + raise ValueError(f"Invalid Notify criteria format: '{criteria}'") + + return { + "type": match.group("type"), + "code": match.group("code"), + "status": match.group("status").lower(), + } + + +# ------------------------------------------------------------------------ +# 🧪 Mock Query Builder Scaffolding (extend with testable methods) +# ------------------------------------------------------------------------ +class MockSelectionBuilder: + """ + Lightweight test harness that mimics SubjectSelectionQueryBuilder behavior. + + This class is used for local testing of SQL fragment builder methods without requiring + the full application context. Developers can reimplement individual _add_criteria_* + methods here for isolated evaluation. + + Usage: + - Add your _add_criteria_* method to this class + - Then create tests in utils/oracle/test_subject_criteria_dev.py to run it + - Use dump_sql() to inspect the generated SQL fragment + """ + + def __init__(self, criteria_key, criteria_value, criteria_comparator=">="): + self.criteria_key = criteria_key + self.criteria_key_name = criteria_key.description + self.criteria_value = criteria_value + self.criteria_comparator = criteria_comparator + self.criteria_index: int = 0 + self.sql_where = [] + self.sql_from = [] + + # ------------------------------------------------------------------------ + # 🖨️ SQL Inspection Utility (used to inspect the SQL fragments - do not remove) + # ------------------------------------------------------------------------ + def dump_sql(self): + parts = [] + + if self.sql_from: + parts.append("-- FROM clause --") + parts.extend(self.sql_from) + + if self.sql_where: + parts.append("-- WHERE clause --") + parts.extend(self.sql_where) + + return "\n".join(parts) + + # ------------------------------------------------------------------------ + # 🔌 Required Internal Stubs (builder compatibility - do not remove) + # ------------------------------------------------------------------------ + def _add_join_to_latest_episode(self) -> None: + """ + Mock stub for adding latest episode join. No-op for test harness. + """ + self.sql_from.append("-- JOIN to latest episode placeholder") + + def _force_not_modifier_is_invalid(self): + # Placeholder for rule enforcement. No-op in mock builder. + pass + + def _dataset_source_for_criteria_key(self) -> dict: + """ + Maps criteria key to dataset table and alias. + """ + key = self.criteria_key + if key == SubjectSelectionCriteriaKey.LATEST_EPISODE_HAS_CANCER_AUDIT_DATASET: + return {"table": "ds_cancer_audit_t", "alias": "cads"} + if ( + key + == SubjectSelectionCriteriaKey.LATEST_EPISODE_HAS_COLONOSCOPY_ASSESSMENT_DATASET + ): + return {"table": "ds_patient_assessment_t", "alias": "dspa"} + if key == SubjectSelectionCriteriaKey.LATEST_EPISODE_HAS_MDT_DATASET: + return {"table": "ds_mdt_t", "alias": "mdt"} + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_join_to_surveillance_review(self): + self.sql_from.append("-- JOIN to surveillance review placeholder") + + +# ------------------------------------------------------------------------ +# 🧪 Add Your Custom _add_criteria_* Test Methods Below +# ------------------------------------------------------------------------ +# e.g., def _add_criteria_example_filter(self): ... +# then use utils/oracle/test_subject_criteria_dev.py to run your scenarios diff --git a/tests_utils/query_builder_test_harness/test_subject_criteria_dev.py b/tests_utils/query_builder_test_harness/test_subject_criteria_dev.py new file mode 100644 index 00000000..89b252e2 --- /dev/null +++ b/tests_utils/query_builder_test_harness/test_subject_criteria_dev.py @@ -0,0 +1,55 @@ +""" +test_subject_criteria_dev.py + +This is a development-only script used to manually test and debug individual +criteria methods from the SubjectSelectionQueryBuilder or MockSelectionBuilder. + +It allows developers to: + - Pass in a specific SubjectSelectionCriteriaKey and value + - Invoke selection logic (e.g. _add_criteria_* methods) + - Inspect the resulting SQL fragments using `dump_sql()` + +Note: + This script is intended for local use only and should NOT be committed with + test content. Add it to your .gitignore after cloning or copying the template. + +See Also: + - mock_selection_builder.py: Test harness for isolated builder method evaluation + - subject_selection_query_builder.py: The production SQL builder implementation +""" + +import sys +import os + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) +print("PYTHONPATH set to:", sys.path[0]) +from tests_utils.query_builder_test_harness.mock_selection_builder import ( + MockSelectionBuilder, +) +from classes.subject_selection_criteria_key import SubjectSelectionCriteriaKey + + +# Helper for mock sequencing +def make_builder(key, value, index=0, comparator="="): + b = MockSelectionBuilder(key, value, comparator) + b.criteria_key = key + b.criteria_key_name = key.name + b.criteria_value = value + b.criteria_index = index + b.criteria_comparator = comparator + return b + + +# === Example usage === +# Replace the examples below with your tests for the method you want to test + +# # === Test: DEMOGRAPHICS_TEMPORARY_ADDRESS (yes) === +# b = make_builder(SubjectSelectionCriteriaKey.DEMOGRAPHICS_TEMPORARY_ADDRESS, "yes") +# b._add_criteria_has_temporary_address() +# print(b.dump_sql()) + +# # === Test: DEMOGRAPHICS_TEMPORARY_ADDRESS (no) === +# b = make_builder(SubjectSelectionCriteriaKey.DEMOGRAPHICS_TEMPORARY_ADDRESS, "no") +# b._add_criteria_has_temporary_address() +# print("=== DEMOGRAPHICS_TEMPORARY_ADDRESS (no) ===") +# print(b.dump_sql(), end="\n\n") diff --git a/tests_utils/query_builder_test_harness/test_subject_selection_query_builder_util.py b/tests_utils/query_builder_test_harness/test_subject_selection_query_builder_util.py new file mode 100644 index 00000000..8b97eca3 --- /dev/null +++ b/tests_utils/query_builder_test_harness/test_subject_selection_query_builder_util.py @@ -0,0 +1,63 @@ +from utils.oracle.subject_selection_query_builder import SubjectSelectionQueryBuilder +from utils.oracle.oracle import OracleDB +from classes.user import User +from classes.subject import Subject +import logging +import pytest + + +def test_subject_selection_query_builder(): + """ + This function demonstrates how to use the builder to create a query + based on specified criteria and user/subject objects. + """ + + criteria = { + "screening status": "Surveillance", + } + user = User() + subject = Subject() + subject.set_screening_status_id(4006) + + builder = SubjectSelectionQueryBuilder() + + query, bind_vars = builder.build_subject_selection_query( + criteria=criteria, user=user, subject=subject, subjects_to_retrieve=1 + ) + + df = OracleDB().execute_query(query, bind_vars) + logging.info(f"DataFrame: {df}") + assert df is not None, "DataFrame should not be None" + assert df.shape[0] == 1, "DataFrame should contain exactly one row" + + criteria = { + "nhs number": "9163626810", + } + user = User() + subject = Subject() + subject.set_nhs_number("9163626810") + query, bind_vars = builder.build_subject_selection_query( + criteria=criteria, user=user, subject=subject, subjects_to_retrieve=1 + ) + + df = OracleDB().execute_query(query, bind_vars) + logging.info(f"DataFrame: {df}") + assert df is not None, "DataFrame should not be None" + assert df.shape[0] == 1, "DataFrame should contain exactly one row" + assert ( + df.iloc[0]["subject_nhs_number"] == "9163626810" + ), "NHS number should match the input" + + criteria = { + "subject has temporary address": "no", + } + user = User() + subject = Subject() + query, bind_vars = builder.build_subject_selection_query( + criteria=criteria, user=user, subject=subject, subjects_to_retrieve=1 + ) + + df = OracleDB().execute_query(query, bind_vars) + logging.info(f"DataFrame: {df}") + assert df is not None, "DataFrame should not be None" + assert df.shape[0] == 1, "DataFrame should contain exactly one row" diff --git a/tests_utils/test_calendar_picker_util.py b/tests_utils/test_calendar_picker_util.py new file mode 100644 index 00000000..81bb1bac --- /dev/null +++ b/tests_utils/test_calendar_picker_util.py @@ -0,0 +1,108 @@ +import pytest +from utils.calendar_picker import CalendarPicker +from datetime import datetime +from playwright.sync_api import Page + +pytestmark = [pytest.mark.utils_local] + + +def test_calculate_v2_calendar_variables(page: Page): + calendar_picker = CalendarPicker(page) + ( + current_month_long, + month_long, + month_short, + current_year, + year, + current_decade, + decade, + current_century, + century, + ) = calendar_picker.calculate_v2_calendar_variables( + datetime(2025, 4, 9), datetime(2020, 6, 9) + ) + + assert current_month_long == "June" + assert month_long == "April" + assert month_short == "Apr" + assert current_year == 2020 + assert year == 2025 + assert current_decade == 2020 + assert decade == 2020 + assert current_century == 2000 + assert century == 2000 + + ( + current_month_long, + month_long, + month_short, + current_year, + year, + current_decade, + decade, + current_century, + century, + ) = calendar_picker.calculate_v2_calendar_variables( + datetime(1963, 1, 28), datetime(2020, 6, 9) + ) + + assert current_month_long == "June" + assert month_long == "January" + assert month_short == "Jan" + assert current_year == 2020 + assert year == 1963 + assert current_decade == 2020 + assert decade == 1960 + assert current_century == 2000 + assert century == 1900 + + ( + current_month_long, + month_long, + month_short, + current_year, + year, + current_decade, + decade, + current_century, + century, + ) = calendar_picker.calculate_v2_calendar_variables( + datetime(2356, 12, 18), datetime(2020, 6, 9) + ) + + assert current_month_long == "June" + assert month_long == "December" + assert month_short == "Dec" + assert current_year == 2020 + assert year == 2356 + assert current_decade == 2020 + assert decade == 2350 + assert current_century == 2000 + assert century == 2300 + + +def test_calculate_years_and_months_to_traverse(page: Page): + calendar_picker = CalendarPicker(page) + years_to_traverse, months_to_traverse = ( + calendar_picker.calculate_years_and_months_to_traverse( + datetime(2356, 12, 18), datetime(2020, 6, 9) + ) + ) + assert years_to_traverse == -336 + assert months_to_traverse == -6 + + years_to_traverse, months_to_traverse = ( + calendar_picker.calculate_years_and_months_to_traverse( + datetime(2020, 12, 1), datetime(2020, 6, 9) + ) + ) + assert years_to_traverse == 0 + assert months_to_traverse == -6 + + years_to_traverse, months_to_traverse = ( + calendar_picker.calculate_years_and_months_to_traverse( + datetime(1961, 1, 30), datetime(2020, 6, 9) + ) + ) + assert years_to_traverse == 59 + assert months_to_traverse == 5 diff --git a/tests_utils/test_date_time_utils.py b/tests_utils/test_date_time_utils.py index a7a2af0e..66d92034 100644 --- a/tests_utils/test_date_time_utils.py +++ b/tests_utils/test_date_time_utils.py @@ -2,15 +2,19 @@ import utils.date_time_utils from datetime import datetime, timedelta - pytestmark = [pytest.mark.utils] + def test_current_datetime(): dtu = utils.date_time_utils.DateTimeUtils() current_date = datetime.now() assert dtu.current_datetime() == current_date.strftime("%d/%m/%Y %H:%M") - assert dtu.current_datetime("%Y-%m-%d %H:%M") == current_date.strftime("%Y-%m-%d %H:%M") - assert dtu.current_datetime("%d %B %Y %H:%M") == current_date.strftime("%d %B %Y %H:%M") + assert dtu.current_datetime("%Y-%m-%d %H:%M") == current_date.strftime( + "%Y-%m-%d %H:%M" + ) + assert dtu.current_datetime("%d %B %Y %H:%M") == current_date.strftime( + "%d %B %Y %H:%M" + ) def test_format_date(): @@ -28,15 +32,26 @@ def test_add_days(): assert new_date == date + timedelta(days=5) -def test_get_day_of_week_for_today(): +# Valid weekdays for testing get_day_of_week +VALID_WEEKDAYS = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", +] + + +def test_get_day_of_week_with_specific_date(): dtu = utils.date_time_utils.DateTimeUtils() - date = datetime.now() - day_of_week = dtu.get_a_day_of_week(date) - assert day_of_week in ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + date = datetime(2023, 11, 8) # Known Wednesday + day_of_week = dtu.get_day_of_week(date) + assert day_of_week in VALID_WEEKDAYS -def test_get_a_day_of_week(): +def test_get_day_of_week_with_default_today(): dtu = utils.date_time_utils.DateTimeUtils() - date = datetime(2023, 11, 8) - day_of_week = dtu.get_a_day_of_week(date) - assert day_of_week in ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + day_of_week = dtu.get_day_of_week() + assert day_of_week in VALID_WEEKDAYS diff --git a/tests_utils/test_nhs_number_tools.py b/tests_utils/test_nhs_number_tools.py index e7b7c38e..44c3d2fe 100644 --- a/tests_utils/test_nhs_number_tools.py +++ b/tests_utils/test_nhs_number_tools.py @@ -1,18 +1,24 @@ import pytest from utils.nhs_number_tools import NHSNumberTools, NHSNumberToolsException - pytestmark = [pytest.mark.utils] + def test_nhs_number_checks() -> None: assert NHSNumberTools._nhs_number_checks("1234567890") == None - with pytest.raises(Exception, match=r'The NHS number provided \(A234567890\) is not numeric.'): + with pytest.raises( + Exception, match=r"The NHS number provided \(A234567890\) is not numeric." + ): NHSNumberTools._nhs_number_checks("A234567890") - with pytest.raises(NHSNumberToolsException, match=r'The NHS number provided \(123\) is not 10 digits'): + with pytest.raises( + NHSNumberToolsException, + match=r"The NHS number provided \(123\) is not 10 digits", + ): NHSNumberTools._nhs_number_checks("123") + def test_spaced_nhs_number() -> None: assert NHSNumberTools.spaced_nhs_number("1234567890") == "123 456 7890" assert NHSNumberTools.spaced_nhs_number(3216549870) == "321 654 9870" diff --git a/tests_utils/test_subject_selection_query_builder.py b/tests_utils/test_subject_selection_query_builder.py new file mode 100644 index 00000000..c59d4a3a --- /dev/null +++ b/tests_utils/test_subject_selection_query_builder.py @@ -0,0 +1,73 @@ +import pytest +from utils.oracle.subject_selection_query_builder import ( + SubjectSelectionQueryBuilder, + SubjectSelectionCriteriaKey, +) +from classes.subject import Subject +from classes.user import User + + +@pytest.fixture +def builder(): + return SubjectSelectionQueryBuilder() + + +@pytest.fixture +def dummy_subject(): + subject = Subject() + subject.screening_status_change_date = None + subject.date_of_death = None + return subject + + +@pytest.fixture +def dummy_user(): + return User() + + +def test_add_criteria_subject_age_y_d(builder, dummy_user, dummy_subject): + # This format triggers the 'y/d' logic branch + criteria = {"subject age (y/d)": "60/0"} + + builder._add_variable_selection_criteria(criteria, dummy_user, dummy_subject) + where_clause = " ".join(builder.sql_where) + + assert "c.date_of_birth" in where_clause + assert "ADD_MONTHS(TRUNC(TRUNC(SYSDATE))" in where_clause + + +def test_add_criteria_subject_hub_code_with_enum(builder, dummy_user, dummy_subject): + criteria = {"subject hub code": "user organisation"} + builder._add_variable_selection_criteria(criteria, dummy_user, dummy_subject) + sql = " ".join(builder.sql_where) + assert "c.hub_id" in sql + assert "SELECT hub.org_id" in sql + + +def test_invalid_criteria_key_raises_exception(builder, dummy_user, dummy_subject): + criteria = {"invalid key": "value"} + with pytest.raises(Exception): + builder._add_variable_selection_criteria(criteria, dummy_user, dummy_subject) + + +def test_preprocess_commented_criterion_skips_processing(builder, dummy_subject): + result = builder._preprocess_criteria( + "subject age", "#this is ignored", dummy_subject + ) + assert result is False + + +def test_dispatch_known_key_executes(builder, dummy_user, dummy_subject): + # Arrange + builder.criteria_key = SubjectSelectionCriteriaKey.SUBJECT_AGE + builder.criteria_value = "60" + builder.criteria_comparator = " = " + builder.criteria_key_name = "subject age" + + # Act + builder._dispatch_criteria_key(dummy_user, dummy_subject) + + # Assert + where_clause = " ".join(builder.sql_where) + assert "c.date_of_birth" in where_clause + assert "FLOOR(MONTHS_BETWEEN(TRUNC(SYSDATE), c.date_of_birth)/12)" in where_clause diff --git a/tests_utils/test_user_tools.py b/tests_utils/test_user_tools.py index 014c870a..7ba2bcc7 100644 --- a/tests_utils/test_user_tools.py +++ b/tests_utils/test_user_tools.py @@ -3,11 +3,15 @@ from utils.user_tools import UserTools, UserToolsException from pathlib import Path - pytestmark = [pytest.mark.utils] -def test_retrieve_user(monkeypatch: object) -> None: - monkeypatch.setattr(utils.user_tools, "USERS_FILE", Path(__file__).parent / "resources" / "test_users.json") + +def test_retrieve_user(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + utils.user_tools, + "USERS_FILE", + Path(__file__).parent / "resources" / "test_users.json", + ) test_user = UserTools.retrieve_user("Test User") assert test_user["username"] == "TEST_USER1" @@ -17,5 +21,7 @@ def test_retrieve_user(monkeypatch: object) -> None: assert test_user2["username"] == "TEST_USER2" assert test_user2["test_key"] == "TEST B" - with pytest.raises(UserToolsException, match=r'User \[Invalid User\] is not present in users.json'): + with pytest.raises( + UserToolsException, match=r"User \[Invalid User\] is not present in users.json" + ): UserTools.retrieve_user("Invalid User") diff --git a/users.json b/users.json index 2fdbee1b..e3c98183 100644 --- a/users.json +++ b/users.json @@ -1,11 +1,53 @@ { - "_comment": "This file can be used to store data on users for your application, and then pulled through using the utils.user_tools UserTools utility. The documentation for this utility explains how this file is read.", - "Example User 1": { - "username": "EXAMPLE_USER1", - "roles": ["Example Role A"] - }, - "Example User 2": { - "username": "EXAMPLE_USER2", - "roles": ["Example Role B", "Example Role C"] - } + "_comment": "This file can be used to store data on users for your application, and then pulled through using the utils.user_tools UserTools utility. The documentation for this utility explains how this file is read.", + + "Hub Manager State Registered at BCS01": { + "username": "BCSS401", + "roles": [ + "Hub Manager State Registered, Midlands and North West" + ] + }, + "Hub Manager at BCS01": { + "username": "BCSS402", + "roles": [ + "Hub Manager, Midlands and North West" + ] + }, + "Screening Centre Manager at BCS001": { + "username": "BCSS118", + "roles": [ + "Screening Centre Manager for Wolverhampton, Midlands and North West" + ] + }, + "ScreeningAssistant at BCS02": { + "username": "BCSS412", + "roles": [ + "Screening Assistant" + ] +}, + "Team Leader at BCS01": { + "username": "BCSS403", + "roles": [ + "Team Leader" + ] +}, + "Hub Manager State Registered at BCS02": { + "username": "BCSS408", + "roles": [ + "Hub Manager State Registered, Southern Bowel Cancer Screening Programme Hub" + ] + }, + "Hub Manager at BCS02": { + "username": "BCSS409", + "roles": [ + "Hub Manager, Southern Bowel Cancer Screening Programme Hub" + ] + }, + "Specialist Screening Practitioner at BCS009 & BCS001": { + "username": "BCSS120", + "roles": [ + "Coventry and Warwickshire Bowel Cancer Screening Centre, MultiOrgUser", + "Wolverhampton Bowel Cancer Screening Centre, MultiOrgUser" + ] + } } diff --git a/utils/batch_processing.py b/utils/batch_processing.py new file mode 100644 index 00000000..1fd0a56c --- /dev/null +++ b/utils/batch_processing.py @@ -0,0 +1,178 @@ +from pages.base_page import BasePage +from pages.communication_production.communications_production_page import ( + CommunicationsProductionPage, +) +from pages.communication_production.manage_active_batch_page import ( + ManageActiveBatchPage, +) +from pages.communication_production.batch_list_page import ( + ActiveBatchListPage, + ArchivedBatchListPage, +) +from utils.screening_subject_page_searcher import verify_subject_event_status_by_nhs_no +from utils.oracle.oracle_specific_functions import get_nhs_no_from_batch_id +from utils.oracle.oracle import OracleDB +from utils.pdf_reader import extract_nhs_no_from_pdf +import os +import pytest +from playwright.sync_api import Page +import logging +import pandas as pd + + +def batch_processing( + page: Page, + batch_type: str, + batch_description: str, + latest_event_status: str | list, + run_timed_events: bool = False, + get_subjects_from_pdf: bool = False, +) -> None: + """ + This is used to process batches. + + Args: + page (Page): This is the playwright page object + batch_type (str): The event code of the batch. E.g. S1 or S9 + batch_description (str): The description of the batch. E.g. Pre-invitation (FIT) + latest_event_status (str | list): The status the subject will get updated to after the batch has been processed. + run_timed_events (bool): An optional input that executes bcss_timed_events if set to True + get_subjects_from_pdf (bool): An optional input to change the method of retrieving subjects from the batch from the DB to the PDF file. + """ + logging.info(f"Processing {batch_type} - {batch_description} batch") + BasePage(page).click_main_menu_link() + BasePage(page).go_to_communications_production_page() + CommunicationsProductionPage(page).go_to_active_batch_list_page() + ActiveBatchListPage(page).enter_event_code_filter(batch_type) + + batch_description_cells = page.locator(f"//td[text()='{batch_description}']") + + if ( + batch_description_cells.count() == 0 + and page.locator("td", has_text="No matching records found").is_visible() + ): + pytest.fail(f"No {batch_type} {batch_description} batch found") + + for i in range(batch_description_cells.count()): + row = batch_description_cells.nth(i).locator("..") # Get the parent row + + # Check if the row contains "Open" + if row.locator("td", has_text="Open").count() > 0: + # Find the first link in that row and click it + link = row.locator("a").first + link_text = link.inner_text() # Get the batch id dynamically + logging.info( + f"Successfully found open '{batch_type} - {batch_description}' batch" + ) + link.click() + break + elif (i + 1) == batch_description_cells.count(): + pytest.fail(f"No open '{batch_type} - {batch_description}' batch found") + + if get_subjects_from_pdf: + logging.info(f"Getting NHS Numbers for batch {link_text} from the PDF File") + nhs_no_df = prepare_and_print_batch(page, link_text, get_subjects_from_pdf) + else: + logging.info(f"Getting NHS Numbers for batch {link_text} from the DB") + prepare_and_print_batch(page, link_text, get_subjects_from_pdf) + nhs_no_df = get_nhs_no_from_batch_id(link_text) + + check_batch_in_archived_batch_list(page, link_text) + + if nhs_no_df is None: + raise ValueError("No NHS numbers were retrieved for the batch") + + for subject in range(len(nhs_no_df)): + nhs_no = nhs_no_df["subject_nhs_number"].iloc[subject] + logging.info(f"Verifying the event status for subject: {nhs_no}") + verify_subject_event_status_by_nhs_no(page, nhs_no, latest_event_status) + + if run_timed_events: + OracleDB().exec_bcss_timed_events(nhs_no_df) + + +def prepare_and_print_batch( + page: Page, link_text: str, get_subjects_from_pdf: bool = False +) -> pd.DataFrame | None: + """ + This prepares the batch, retrieves the files and confirms them as printed + Once those buttons have been pressed it waits for the message 'Batch Successfully Archived' + + Args: + page (Page): This is the playwright page object + link_text (str): The batch ID + get_subjects_from_pdf (bool): An optional input to change the method of retrieving subjects from the batch from the DB to the PDF file. + + Returns: + nhs_no_df (pd.DataFrame | None): if get_subjects_from_pdf is True, this is a DataFrame with the column 'subject_nhs_number' and each NHS number being a record, otherwise it is None + """ + ManageActiveBatchPage(page).click_prepare_button() + page.wait_for_timeout( + 1000 + ) # This one second timeout does not affect the time to execute, as it is just used to ensure the reprepare batch button is clicked and does not instantly advance to the next step + ManageActiveBatchPage(page).reprepare_batch_text.wait_for(timeout=60000) + + # This loops through each Retrieve button and clicks each one + retrieve_button_count = 0 + try: + for retrieve_button in range( + ManageActiveBatchPage(page).retrieve_button.count() + ): + retrieve_button_count += 1 + logging.info(f"Clicking retrieve button {retrieve_button_count}") + # Start waiting for the pdf download + with page.expect_download() as download_info: + # Perform the action that initiates download. The line below is running in a FOR loop to click every retrieve button as in some cases more than 1 is present + ManageActiveBatchPage(page).retrieve_button.nth(retrieve_button).click() + download_file = download_info.value + file = download_file.suggested_filename + # Wait for the download process to complete and save the downloaded file in a temp folder + download_file.save_as(file) + nhs_no_df = ( + extract_nhs_no_from_pdf(file) + if file.endswith(".pdf") and get_subjects_from_pdf + else None + ) + os.remove(file) # Deletes the file after extracting the necessary data + except Exception as e: + pytest.fail(f"No retrieve button available to click: {str(e)}") + + # This loops through each Confirm printed button and clicks each one + try: + for confirm_printed_button in range(retrieve_button_count): + logging.info( + f"Clicking confirm printed button {confirm_printed_button + 1}" + ) + ManageActiveBatchPage(page).safe_accept_dialog( + ManageActiveBatchPage(page).confirm_button.nth(0) + ) + except Exception as e: + pytest.fail(f"No confirm printed button available to click: {str(e)}") + + try: + ActiveBatchListPage(page).batch_successfully_archived_msg.wait_for() + + logging.info(f"Batch {link_text} successfully archived") + except Exception as e: + pytest.fail(f"Batch successfully archived message is not shown: {str(e)}") + + return nhs_no_df + + +def check_batch_in_archived_batch_list(page: Page, link_text) -> None: + """ + Checks the the batch that was just prepared and printed is now visible in the archived batch list. + + Args: + page (Page): This is the playwright page object + link_text (str): The batch ID + """ + BasePage(page).click_main_menu_link() + BasePage(page).go_to_communications_production_page() + CommunicationsProductionPage(page).go_to_archived_batch_list_page() + ArchivedBatchListPage(page).enter_id_filter(link_text) + try: + ArchivedBatchListPage(page).verify_table_data(link_text) + logging.info(f"Batch {link_text} visible in archived batch list") + except Exception as e: + logging.error(f"Batch {link_text} not visible in archived batch list: {str(e)}") diff --git a/utils/calendar_picker.py b/utils/calendar_picker.py new file mode 100644 index 00000000..ff182b47 --- /dev/null +++ b/utils/calendar_picker.py @@ -0,0 +1,450 @@ +from datetime import datetime +from utils.date_time_utils import DateTimeUtils +from playwright.sync_api import Page, Locator +from pages.base_page import BasePage +from sys import platform +import pytest + + +class CalendarPicker(BasePage): + def __init__(self, page: Page): + super().__init__(page) + self.page = page + # V1 Calendar picker locators + self.v1_prev_year = self.page.get_by_role("cell", name="«").locator("div") + self.v1_prev_month = self.page.get_by_role("cell", name="‹").locator("div") + self.v1_next_month = self.page.get_by_role("cell", name="›").locator("div") + self.v1_next_year = self.page.get_by_role("cell", name="»").locator("div") + self.v1_calendar_current_date = self.page.locator( + 'td.title[colspan="5"][style="cursor: move;"]' + ) + self.v1_today_button = self.page.get_by_text("Today", exact=True) + # V2 Calendar picker locators + self.v2date_picker_switch = self.page.locator( + 'th.datepicker-switch[colspan="5"]:visible' + ) + # Book Appointment picker locators + self.appointments_prev_month = self.page.get_by_role( + "link", name="<-", exact=True + ) + self.appointments_next_month = self.page.get_by_role( + "link", name="->", exact=True + ) + + # Calendar Methods + def calendar_picker_ddmmyyyy(self, date: datetime, locator: Locator) -> None: + """ + Enters a date in the format dd/mm/yyyy (e.g. 16/01/2025) into the specified locator + This is for the older style pages v1 + + Args: + date (datetime): The date we want to enter into the locator + locator (Locator): The locator of the element in which we want to enter the date + """ + formatted_date = DateTimeUtils.format_date(date) + locator.fill(formatted_date) + locator.press("Enter") + + def calendar_picker_ddmonyy(self, date: datetime, locator: Locator) -> None: + """ + Enters a date in the format dd month yy (e.g. 16 Jan 25) into the specified locator + This is for the more modern style pages v2 + + Args: + date (datetime): The date we want to enter into the locator + locator (Locator): The locator of the element in which we want to enter the date + """ + if platform == "win32": # Windows + formatted_date = DateTimeUtils.format_date(date, "%#d %b %Y") + else: # Linux or Mac + formatted_date = DateTimeUtils.format_date(date, "%-d %b %Y") + locator.fill(formatted_date) + locator.press("Enter") + + def calculate_years_and_months_to_traverse( + self, date: datetime, current_date: datetime + ) -> tuple[int, int]: + """ + This function is used when using the v1 calendar picker + It calculates how many years and months it needs to traverse + + Args: + date (datetime): The date we want to go to + current_date (datetime): The current date + + Returns: + years_to_traverse (int): The number of years we need to traverse + years_to_traverse (int): The number of months we need to traverse + """ + years_to_traverse = int(DateTimeUtils.format_date(current_date, "%Y")) - int( + DateTimeUtils.format_date(date, "%Y") + ) + months_to_traverse = int(DateTimeUtils.format_date(current_date, "%m")) - int( + DateTimeUtils.format_date(date, "%m") + ) + return years_to_traverse, months_to_traverse + + def traverse_years_in_v1_calendar(self, years_to_traverse: int) -> None: + """ + This function traverses the years on the v1 calendar picker by the number specified in years_to_traverse + + Args: + years_to_traverse (int): The number of years we need to traverse + """ + if years_to_traverse > 0: + for _ in range(years_to_traverse): + self.click(self.v1_prev_year) + elif years_to_traverse < 0: + for _ in range(years_to_traverse * -1): + self.click(self.v1_next_year) + + def traverse_months_in_v1_calendar(self, months_to_traverse: int) -> None: + """ + This function traverses the months on the v1 calendar picker by the number specified in months_to_traverse + + Args: + months_to_traverse (int): The number of months we need to traverse + """ + if months_to_traverse > 0: + for _ in range(months_to_traverse): + self.click(self.v1_prev_month) + elif months_to_traverse < 0: + for _ in range(months_to_traverse * -1): + self.click(self.v1_next_month) + + def select_day(self, date: datetime) -> None: + """ + This function is used by both the v1 and v2 calendar picker + It extracts the day from the date and then selects that value in the calendar picker + + Args: + date (datetime): The date we want to select + """ + if platform == "win32": # Windows + day_to_select = DateTimeUtils.format_date(date, "%#d") + else: # Linux or Mac + day_to_select = DateTimeUtils.format_date(date, "%-d") + number_of_cells_with_day = self.page.get_by_role( + "cell", name=day_to_select + ).count() + + all_matching_days = self.page.get_by_role( + "cell", name=day_to_select, exact=True + ).all() + + matching_days = [ + day + for day in all_matching_days + if day.evaluate("el => el.textContent.trim()") == day_to_select + ] + + if int(day_to_select) < 15 and number_of_cells_with_day > 1: + self.click(matching_days[0].first) + elif int(day_to_select) > 15 and number_of_cells_with_day > 1: + self.click(matching_days[-1].last) + else: + self.click(matching_days[0]) + + def v1_calender_picker(self, date: datetime) -> None: + """ + This is the main method used to traverse the v1 calendar picker (e.g. the one on the subject screening search page) + You provide it with a date and it will call the necessary functions to calculate how to navigate to the specified date + + Args: + date (datetime): The date we want to select + """ + + if DateTimeUtils.format_date(date, "%d/%m/%Y") == DateTimeUtils.format_date( + datetime.today(), "%d/%m/%Y" + ): + self.click(self.v1_today_button) + return + + current_date = datetime.strptime( + self.v1_calendar_current_date.inner_text(), "%B, %Y" + ) + years_to_traverse, months_to_traverse = ( + self.calculate_years_and_months_to_traverse(date, current_date) + ) + self.traverse_years_in_v1_calendar(years_to_traverse) + + self.traverse_months_in_v1_calendar(months_to_traverse) + + self.select_day(date) + + def calculate_v2_calendar_variables( + self, date: datetime, current_date: datetime + ) -> tuple[str, str, str, int, int, int, int, int, int]: + """ + This function calculates all of the variables needed to traverse through the v2 calendar picker + + Args: + date (datetime): The date we want to select + current_date (datetime): The current date + + Returns: + current_month_long (str): The current month in long format (e.g. April) + month_long (str): The wanted month in long format (e.g. June) + month_short (str): The wanted month is short format (e.g. Jun) + current_year (int): The current year in yyyy format (e.g. 2025) + year (int): The wanted year in yyyy format (e.g. 1983) + current_decade (int): The current decade in yyyy format (e.g. 2020) + decade (int): The wanted decade in yyyy format (e.g. 1980) + current_century (int): The current century in yyyy format (e.g. 2000/2100) + century (int): The wanted century in yyyy format (e.g. 1900) + """ + current_month_long = DateTimeUtils.format_date(current_date, "%B") + current_year = int(DateTimeUtils.format_date(current_date, "%Y")) + current_century = (current_year // 100) * 100 + current_decade = ( + ((current_year - current_century) // 10) * 10 + ) + current_century + + year = int(DateTimeUtils.format_date(date, "%Y")) + century = (year // 100) * 100 + decade = (((year - century) // 10) * 10) + century + month_short = DateTimeUtils.format_date(date, "%b") + month_long = DateTimeUtils.format_date(date, "%B") + + return ( + current_month_long, + month_long, + month_short, + current_year, + year, + current_decade, + decade, + current_century, + century, + ) + + def v2_calendar_picker_traverse_back( + self, + current_month_long: str, + month_long: str, + current_year: int, + year: int, + current_decade: int, + decade: int, + current_century: int, + century: int, + ) -> tuple[bool, bool, bool, bool]: + """ + This function is used to "go back in time" / "expand" on the v2 calendar picker + By selecting the top locator we can increase the range of dates available to be clicked + It uses the variables calculated in 'calculate_v2_calendar_variables' to know which locators to select + + Args: + current_month_long (str): The current month in long format (e.g. April) + month_long (str): The wanted month in long format (e.g. June) + current_year (int): The current year in yyyy format (e.g. 2025) + year (int): The wanted year in yyyy format (e.g. 1983) + current_decade (int): The current decade in yyyy format (e.g. 2020) + decade (int): The wanted decade in yyyy format (e.g. 1980) + current_century (int): The current century in yyyy format (e.g. 2000/2100) + century (int): The wanted century in yyyy format (e.g. 1900) + + Returns: + click_month (bool): True/False depending on if we clicked on the month + click_year (bool): True/False depending on if we clicked on the year + click_decade (bool): True/False depending on if we clicked on the decade + click_century (bool): True/False depending on if we clicked on the century + """ + + click_month = False + click_year = False + click_decade = False + click_century = False + + if current_month_long != month_long: + self.click(self.v2date_picker_switch) + click_month = True + if current_year != year: + self.click(self.v2date_picker_switch) + click_year = True + if current_decade != decade: + self.click(self.v2date_picker_switch) + click_decade = True + if current_century != century: + self.click(self.v2date_picker_switch) + click_century = True + + return click_month, click_year, click_decade, click_century + + def v2_calendar_picker_traverse_forward( + self, + click_month: bool, + click_year: bool, + click_decade: bool, + click_century: bool, + century: str, + decade: str, + year: str, + month_short: str, + ) -> None: + """ + This function is used to "go forward" through the v2 calendar picker after the date range has been expanded + It uses the variables calculated in 'calculate_v2_calendar_variables' to know which locators to select + + Args: + click_month (bool): True/False depending on if we need to click on the month + click_year (bool): True/False depending on if we need to click on the year + click_decade (bool): True/False depending on if we need to click on the decade + click_century (bool): True/False depending on if we need to click on the century + century (str): The century of the date we want to select + decade (str): The decade of the date we want to select + year (str): The year of the date we want to select + month_short (str): The month of the date we want to select + """ + + if click_century: + self.click(self.page.get_by_text(str(century), exact=True).first) + if click_decade: + self.click(self.page.get_by_text(str(decade), exact=True).first) + if click_year: + self.click(self.page.get_by_text(str(year), exact=True)) + if click_month: + self.click(self.page.get_by_text(str(month_short))) + + def v2_calendar_picker(self, date: datetime) -> None: + """ + This is the main method to navigate the v2 calendar picker (like the one on the Active Batch List page) + This calls all the relevant functions in order to know how to traverse the picker + + Args: + date (datetime): The date we want to select + """ + current_date = datetime.today() + ( + current_month_long, + month_long, + month_short, + current_year, + year, + current_decade, + decade, + current_century, + century, + ) = self.calculate_v2_calendar_variables(date, current_date) + + click_month, click_year, click_decade, click_century = ( + self.v2_calendar_picker_traverse_back( + current_month_long, + month_long, + current_year, + year, + current_decade, + decade, + current_century, + century, + ) + ) + + self.v2_calendar_picker_traverse_forward( + click_month, + click_year, + click_decade, + click_century, + str(century), + str(decade), + str(year), + month_short, + ) + + self.select_day(date) + + def book_first_eligible_appointment( + self, + current_month_displayed: str, + locator: Locator, + bg_colours: list, + ) -> None: + """ + This is used to select the first eligible appointment date + It first sets the calendar to the current month + Then gets all available dates, and then clicks on the first one starting from today's date + If no available dates are found it moves onto the next month and repeats this 2 more times + If in the end no available dates are found the test will fail. + + Args: + current_month_displayed (str): The current month that is displayed by the calendar + locator (Locator): The locator of the cells containing the days + bg_colours (list): A list containing all of the background colours of cells we would like to select + """ + current_month_displayed_int = DateTimeUtils().month_string_to_number( + current_month_displayed + ) + if platform == "win32": # Windows + current_month_int = int(DateTimeUtils.format_date(datetime.now(), "%#m")) + else: # Linux or Mac + current_month_int = int(DateTimeUtils.format_date(datetime.now(), "%-m")) + + self.book_appointments_go_to_month( + current_month_displayed_int, current_month_int + ) + + months_looped = 0 + appointment_clicked = False + while ( + months_looped < 3 and not appointment_clicked + ): # This loops through this month + next two months to find available appointments. If none found it has failed + appointment_clicked = self.check_for_eligible_appointment_dates( + locator, bg_colours + ) + + if not appointment_clicked: + self.click(self.appointments_next_month) + months_looped += 1 + + if not appointment_clicked: + pytest.fail("No available appointments found for the current month") + + def book_appointments_go_to_month( + self, current_displayed_month: int, wanted_month: int + ): + """ + This is used to move the calendar on the appointments page to the wanted month + + Args: + current_displayed_month (int): The current month shown as an integer + wanted_month (int): The month we want to go to as an integer + """ + month_difference = current_displayed_month - wanted_month + if month_difference > 0: + for _ in range(month_difference): + self.click(self.appointments_prev_month) + elif month_difference < 0: + for _ in range(month_difference * -1): + self.click(self.appointments_next_month) + + def check_for_eligible_appointment_dates( + self, locator: Locator, bg_colours: list + ) -> bool: + """ + This function loops through all of the appointment date cells and if the background colour matches + It then checks that the length of the name is less than 5. + This is done the name length is the only differentiating factor between the two calendar tables on the page + - 1st table has a length of 4 (e.g. wed2, fri5) and the 2nd table has a length of 5 (e.g. wed11, fri14) + + Args: + locator (Locator): The locator of the cells containing the days + bg_colours (list): A list containing all of the background colours of cells we would like to select + + Returns: + True + """ + + locator_count = locator.count() + + for i in range(locator_count): + locator_element = locator.nth(i) + background_colour = locator_element.evaluate( + "el => window.getComputedStyle(el).backgroundColor" + ) + + if background_colour in bg_colours: + value = locator_element.get_attribute("name") + if value is not None and len(value) < 5: + self.click(locator.nth(i)) + return True + return False diff --git a/utils/dataset_field_util.py b/utils/dataset_field_util.py new file mode 100644 index 00000000..9956f51c --- /dev/null +++ b/utils/dataset_field_util.py @@ -0,0 +1,113 @@ +from playwright.sync_api import Page, Locator + + +class DatasetFieldUtil: + + def __init__(self, page: Page): + self.page = page + + def get_input_locator_for_field(self, text: str) -> Locator: + """ + Matches input elements that are to the right of any element matching the inner selector, at any vertical position. + + Args: + text (str): The text of the element you want to get the input locator of + + Returns: + Locator: the locator of the input + """ + return self.page.locator(f'input:right-of(:text(\"{text}\"))').first + + def populate_input_locator_for_field( + self, text: str, value: str + ) -> None: + """ + Inputs a value into an input to the right of any element matching the inner selector, at any vertical position. + + Args: + text (str): The text of the element you want to get the input locator of + value (str): The value you want to input + """ + locator = self.get_input_locator_for_field(text) + locator.fill(value) + + def get_select_locator_for_field(self, text: str) -> Locator: + """ + Matches select elements that are to the right of any element matching the inner selector, at any vertical position. + + Args: + text (str): The text of the element you want to get the select locator of + + Returns: + Locator: the locator of the input + """ + return self.page.locator(f'select:right-of(:text(\"{text}\"))').first + + def populate_select_locator_for_field( + self, text: str, option: str + ) -> None: + """ + Matches select elements that are to the right of any element matching the inner selector, at any vertical position. + + Args: + text (str): The text of the element you want to get the select locator of. + option (str): The option you want to select + """ + locator = self.get_select_locator_for_field(text) + locator.select_option(option) + + def get_input_locator_for_field_inside_div(self, text: str, div: str) -> Locator: + """ + Matches input elements that are to the right of any element matching the inner selector, at any vertical position. + + Args: + text (str): The text of the element you want to get the input locator of + div (str): The ID of the DIV the text belongs in + + Returns: + Locator: the locator of the input + """ + container = self.page.locator(f"div#{div}") + return container.locator(f'input:right-of(:text(\"{text}\"))').first + + def populate_input_locator_for_field_inside_div( + self, text: str, div: str, value: str + ) -> None: + """ + Inputs a value into an input to the right of any element matching the inner selector, at any vertical position. + + Args: + text (str): The text of the element you want to get the input locator of + div (str): The ID of the DIV the text belongs in + value (str): The value you want to input + """ + locator = self.get_input_locator_for_field_inside_div(text, div) + locator.fill(value) + + def get_select_locator_for_field_inside_div(self, text: str, div: str) -> Locator: + """ + Matches select elements that are to the right of any element matching the inner selector, at any vertical position. + + Args: + text (str): The text of the element you want to get the select locator of + div (str): The ID of the DIV the text belongs in + + Returns: + Locator: the locator of the input + """ + container = self.page.locator(f"div#{div}") + return container.locator(f'select:right-of(:text(\"{text}\"))').first + + def populate_select_locator_for_field_inside_div( + self, text: str, div: str, option: str + ) -> None: + """ + Matches select elements that are to the right of any element matching the inner selector, at any vertical position. + + Args: + text (str): The text of the element you want to get the select locator of. + div (str): The ID of the DIV the text belongs in + option (str): The option you want to select + """ + locator = self.get_select_locator_for_field_inside_div(text, div) + locator.select_option(option) diff --git a/utils/date_time_utils.py b/utils/date_time_utils.py index ec823e2c..b7e26e95 100644 --- a/utils/date_time_utils.py +++ b/utils/date_time_utils.py @@ -1,4 +1,6 @@ from datetime import datetime, timedelta +from typing import Optional +import random class DateTimeUtils: @@ -45,25 +47,108 @@ def add_days(date: datetime, days: float) -> datetime: return date + timedelta(days=days) @staticmethod - def get_day_of_week_for_today(date: datetime) -> str: - """Gets the day of the week (e.g., Monday, Tuesday) from the specified date. + def get_day_of_week(date: Optional[datetime] = None) -> str: + """ + Returns the day of the week (e.g., Monday, Tuesday) for the given date. + If no date is provided, uses today’s date. Args: - date (datetime): The current date using the now function + date (Optional[datetime]): The date to inspect. Defaults to now. Returns: - str: The day of the week relating to the specified date. + str: Day of week corresponding to the date. """ + date = date or datetime.now() return date.strftime("%A") @staticmethod - def get_a_day_of_week(date: datetime) -> str: - """Gets the day of the week (e.g., Monday, Tuesday) from the specified date. + def report_timestamp_date_format() -> str: + """Gets the current datetime in the timestamp format used on the report pages. + Returns: + str: The current date and time in the format 'dd/mm/yyyy at hh:mm:ss'. + e.g. '24/01/2025 at 12:00:00' + """ + + return DateTimeUtils.format_date(datetime.now(), "%d/%m/%Y at %H:%M:%S") + + @staticmethod + def fobt_kits_logged_but_not_read_report_timestamp_date_format() -> str: + """Gets the current datetime in the format used for FOBT Kits Logged but Not Read report. + Returns: + str: The current date and time in the format 'dd MonthName yyyy hh:mm:ss'. + e.g. '24 Jan 2025 12:00:00' + """ + + return DateTimeUtils.format_date(datetime.now(), "%d %b %Y %H:%M:%S") + + @staticmethod + def screening_practitioner_appointments_report_timestamp_date_format() -> str: + """Gets the current datetime in the format used for the screening practitioner appointments report. + Returns: + str: the current datetime in the format 'dd.mm.yyyy at hh:mm:ss' + e.g. '24.01.2025 at 12:00:00' + """ + + return DateTimeUtils.format_date(datetime.now(), "%d.%m.%Y at %H:%M:%S") + + @staticmethod + def month_string_to_number(string: str) -> int: + """ + This is used to convert a month from a string to an integer. + It accepts the full month or the short version and is not case sensitive + """ + months = { + "jan": 1, + "feb": 2, + "mar": 3, + "apr": 4, + "may": 5, + "jun": 6, + "jul": 7, + "aug": 8, + "sep": 9, + "oct": 10, + "nov": 11, + "dec": 12, + } + month_short = string.strip()[:3].lower() + + try: + out = months[month_short] + return out + except Exception: + raise ValueError( + f"'{string}' is not a valid month name. Accepted values are: {', '.join(months.keys())}" + ) + + @staticmethod + def generate_unique_weekday_date(start_year: int = 2025) -> str: + """ + Returns a random future weekday (Mon–Fri) date from the given year onward. + + The result is in 'dd/mm/yyyy' format and useful for automated tests needing + unique, non-weekend dates. Uses non-cryptographic randomness for variability between runs. Args: - date (datetime): The date for which the day of the week will be returned. + start_year (int): The minimum year from which the date may be generated. Defaults to 2025. Returns: - str: The day of the week relating to the specified date. + str: A future weekday date in the format 'dd/mm/yyyy'. """ - return date.strftime("%A") + # Start from tomorrow to ensure future date + base_date = datetime.now() + timedelta(days=1) + + # Keep moving forward until we find a weekday in 2025 or later + while True: + if base_date.weekday() < 5 and base_date.year >= start_year: + break + base_date += timedelta(days=1) + + # Add randomness to avoid repeated values across runs + base_date += timedelta(days=random.randint(0, 10)) + + # Re-check for weekday after shift + while base_date.weekday() >= 5: + base_date += timedelta(days=1) + + return base_date.strftime("%d/%m/%Y") diff --git a/utils/fit_kit.py b/utils/fit_kit.py new file mode 100644 index 00000000..7193cc01 --- /dev/null +++ b/utils/fit_kit.py @@ -0,0 +1,162 @@ +from utils.oracle.oracle_specific_functions import ( + get_kit_id_from_db, + get_kit_id_logged_from_db, +) +from pages.base_page import BasePage +from datetime import datetime +import logging +import pandas as pd +import pytest + + +class FitKitGeneration: + """This class is responsible for generating FIT Device IDs from test kit data.""" + + def create_fit_id_df( + self, + tk_type_id: int, + hub_id: int, + no_of_kits_to_retrieve: int, + ) -> pd.DataFrame: + """ + This function retrieves test kit data from the database for the specified compartment (using the 'get_kit_id_from_db' function from 'oracle_specific_functions.py'). + It then calculates a check digit for each retrieved kit ID and appends it to the kit ID. + Finally, it generates a FIT Device ID by appending an expiry date and a fixed suffix to the kit ID. + + For example: + Given the following inputs: + tk_type_id = 1, hub_id = 101, no_of_kits_to_retrieve = 2 + The function retrieves two kit IDs from the database, e.g., ["ABC123", "DEF456"]. + It calculates the check digit for each kit ID, resulting in ["ABC123-K", "DEF456-M"]. + Then, it generates the FIT Device IDs, e.g., ["ABC123-K122512345/KD00001", "DEF456-M122512345/KD00001"]. + + Args: + tk_type_id (int): The type ID of the test kit. + hub_id (int): The ID of the hub from which to retrieve the kits. + no_of_kits_to_retrieve (int): The number of kits to retrieve from the database. + + Returns: + pd.DataFrame: A DataFrame containing the processed kit IDs, including the calculated check digit + and the final formatted FIT Device ID. + """ + df = get_kit_id_from_db(tk_type_id, hub_id, no_of_kits_to_retrieve) + df["fit_device_id"] = df["kitid"].apply(self.calculate_check_digit) + df["fit_device_id"] = df["fit_device_id"].apply( + self.convert_kit_id_to_fit_device_id + ) + return df + + def calculate_check_digit(self, kit_id: str) -> str: + """ + Calculates the check digit for a given kit ID. + + The check digit is determined by summing the positions of each character in the kit ID + within a predefined character set. The remainder of the sum divided by 43 is used to + find the corresponding character in the character set, which becomes the check digit. + + For example: + Given the kit ID "ABC123", the positions of the characters in the predefined + character set are summed. If the total is 123, the remainder when divided by 43 + is 37. The character at position 37 in the character set is "K". The resulting + kit ID with the check digit appended would be "ABC123-K". + + Args: + kit_id (str): The kit ID to calculate the check digit for. + + Returns: + str: The kit ID with the calculated check digit appended. + """ + logging.info(f"Calculating check digit for kit id: {kit_id}") + total = 0 + char_string = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-. $/+%" + for i in range(len(kit_id)): + total += char_string.index(kit_id[i - 1]) + check_digit = char_string[total % 43] + return f"{kit_id}-{check_digit}" + + def convert_kit_id_to_fit_device_id(self, kit_id: str) -> str: + """ + Converts a Kit ID into a FIT Device ID by appending an expiry date and a fixed suffix. + + The expiry date is calculated by setting the month to December and the year to one year + in the future based on the current date. For example, if the current date is June 2024, + the expiry date will be set to December 2025. + + Args: + kit_id (str): The Kit ID to be converted. + + Returns: + str: The generated FIT Device ID in the format "{kit_id}12{next_year}12345/KD00001". + """ + logging.info(f"Generating FIT Device ID from: {kit_id}") + today = datetime.now() + year = today.strftime("%y") # Get the year from todays date in YY format + return f"{kit_id}12{int(year) + 1}12345/KD00001" + + +class FitKitLogged: + """This class is responsible for processing FIT Device IDs and logging them as normal or abnormal.""" + + def process_kit_data(self, smokescreen_properties: dict) -> list: + """ + This method retrieved the test data needed for compartment 3 and then splits it into two data frames: + - 1 normal + - 1 abnormal + Once the dataframe is split in two it then creates two lists, one for normal and one for abnormal + Each list will either have true or false appended depending on if it is normal or abnormal + """ + # Get test data for compartment 3 + kit_id_df = get_kit_id_logged_from_db(smokescreen_properties) + + # Split dataframe into two different dataframes, normal and abnormal + normal_fit_kit_df, abnormal_fit_kit_df = self.split_fit_kits( + kit_id_df, smokescreen_properties + ) + + # Prepare a list to store device IDs and their respective flags + device_ids = [] + + # Process normal kits (only 1) + if not normal_fit_kit_df.empty: + device_id = normal_fit_kit_df["device_id"].iloc[0] + logging.info( + f"Processing normal kit with Device ID: {device_id}" + ) # Logging normal device_id + device_ids.append((device_id, True)) # Add to the list with normal flag + else: + pytest.fail("No normal kits found for processing.") + + # Process abnormal kits (multiple, loop through) + if not abnormal_fit_kit_df.empty: + for index, row in abnormal_fit_kit_df.iterrows(): + device_id = row["device_id"] + logging.info( + f"Processing abnormal kit with Device ID: {device_id}" + ) # Logging abnormal device_id + device_ids.append( + (device_id, False) + ) # Add to the list with abnormal flag + else: + pytest.fail("No abnormal kits found for processing.") + + return device_ids + + def split_fit_kits( + self, kit_id_df: pd.DataFrame, smokescreen_properties: dict + ) -> tuple[pd.DataFrame, pd.DataFrame]: + """ + This method splits the dataframe into two, 1 normal and 1 abnormal + """ + number_of_normal = int( + smokescreen_properties["c3_eng_number_of_normal_fit_kits"] + ) + number_of_abnormal = int( + smokescreen_properties["c3_eng_number_of_abnormal_fit_kits"] + ) + + # Split dataframe into two dataframes + normal_fit_kit_df = kit_id_df.iloc[:number_of_normal] + abnormal_fit_kit_df = kit_id_df.iloc[ + number_of_normal : number_of_normal + number_of_abnormal + ] + return normal_fit_kit_df, abnormal_fit_kit_df diff --git a/utils/investigation_dataset.py b/utils/investigation_dataset.py new file mode 100644 index 00000000..a50c1b67 --- /dev/null +++ b/utils/investigation_dataset.py @@ -0,0 +1,491 @@ +from playwright.sync_api import Page +from enum import StrEnum +import logging +from pages.base_page import BasePage +from utils.screening_subject_page_searcher import verify_subject_event_status_by_nhs_no +from pages.screening_subject_search.subject_screening_summary_page import ( + SubjectScreeningSummaryPage, +) +from pages.datasets.subject_datasets_page import SubjectDatasetsPage +from pages.screening_subject_search.handover_into_symptomatic_care_page import ( + HandoverIntoSymptomaticCarePage, +) +from utils.calendar_picker import CalendarPicker +from datetime import datetime +from pages.screening_subject_search.diagnostic_test_outcome_page import ( + DiagnosticTestOutcomePage, + OutcomeOfDiagnosticTest, +) +from pages.datasets.investigation_dataset_page import ( + InvestigationDatasetsPage, + SiteLookupOptions, + PractitionerOptions, + TestingClinicianOptions, + AspirantEndoscopistOptions, + DrugTypeOptions, + BowelPreparationQualityOptions, + ComfortOptions, + EndoscopyLocationOptions, + YesNoOptions, + InsufflationOptions, + OutcomeAtTimeOfProcedureOptions, + LateOutcomeOptions, + CompletionProofOptions, + FailureReasonsOptions, + PolypClassificationOptions, + PolypAccessOptions, + PolypInterventionModalityOptions, + PolypInterventionDeviceOptions, + PolypInterventionExcisionTechniqueOptions, +) +from pages.screening_subject_search.advance_fobt_screening_episode_page import ( + AdvanceFOBTScreeningEpisodePage, +) +from utils.dataset_field_util import DatasetFieldUtil +from pages.screening_subject_search.record_diagnosis_date_page import ( + RecordDiagnosisDatePage, +) + + +class InvestigationDatasetResults(StrEnum): + """ + Enum containing the different investigation dataset results. + This is stored here to result the risk of typo's when calling the methods. + """ + + HIGH_RISK = "High Risk" + LNPCP = "LNPCP" + NORMAL = "Normal" + + +class InvestigationDatasetCompletion: + """ + This class is used to complete the investigation dataset forms for a subject. + It contains methods to fill out the forms, and progress episodes, based on the + age of the subject and the test result. + + This class provides methods to: + - Navigate to the investigation datasets page for a subject. + - Fill out investigation dataset forms with default or result-specific data. + - Handle different investigation outcomes (e.g., HIGH_RISK, LNPCP, NORMAL) by populating relevant form fields and sections. + - Add and configure polyp information and interventions to trigger specific result scenarios. + - Save the completed investigation dataset. + """ + + def __init__(self, page: Page): + self.page = page + self.estimate_whole_polyp_size_string = "Estimate of whole polyp size" + self.polyp_access_string = "Polyp Access" + + def complete_with_result(self, nhs_no: str, result: str) -> None: + """This method fills out the investigation dataset forms based on the test result and the subject's age. + Args: + nhs_no (str): The NHS number of the subject. + result (str): The result of the investigation dataset. + Should be one of InvestigationDatasetResults (HIGH_RISK, LNPCP, NORMAL). + """ + if result == InvestigationDatasetResults.HIGH_RISK: + self.go_to_investigation_datasets_page(nhs_no) + self.default_investigation_dataset_forms() + InvestigationDatasetsPage(self.page).select_therapeutic_procedure_type() + self.default_investigation_dataset_forms_continuation() + self.investigation_datasets_failure_reason() + self.polyps_for_high_risk_result() + self.save_investigation_dataset() + elif result == InvestigationDatasetResults.LNPCP: + self.go_to_investigation_datasets_page(nhs_no) + self.default_investigation_dataset_forms() + InvestigationDatasetsPage(self.page).select_therapeutic_procedure_type() + self.default_investigation_dataset_forms_continuation() + self.investigation_datasets_failure_reason() + self.polyps_for_lnpcp_result() + self.save_investigation_dataset() + elif result == InvestigationDatasetResults.NORMAL: + self.go_to_investigation_datasets_page(nhs_no) + self.default_investigation_dataset_forms() + InvestigationDatasetsPage(self.page).select_diagnostic_procedure_type() + self.default_investigation_dataset_forms_continuation() + InvestigationDatasetsPage(self.page).click_show_failure_information() + DatasetFieldUtil(self.page).populate_select_locator_for_field_inside_div( + "Failure Reasons", + "divFailureSection", + FailureReasonsOptions.NO_FAILURE_REASONS, + ) + self.save_investigation_dataset() + else: + logging.error("Incorrect result entered") + + def go_to_investigation_datasets_page(self, nhs_no) -> None: + """This method navigates to the investigation datasets page for a subject. + Args: + nhs_no (str): The NHS number of the subject. + """ + verify_subject_event_status_by_nhs_no( + self.page, nhs_no, "A323 - Post-investigation Appointment NOT Required" + ) + + SubjectScreeningSummaryPage(self.page).click_datasets_link() + SubjectDatasetsPage(self.page).click_investigation_show_datasets() + + def default_investigation_dataset_forms(self) -> None: + """This method fills out the first part of the default investigation dataset form.""" + # Investigation Dataset + InvestigationDatasetsPage(self.page).select_site_lookup_option( + SiteLookupOptions.RL401 + ) + InvestigationDatasetsPage(self.page).select_practitioner_option( + PractitionerOptions.AMID_SNORING + ) + InvestigationDatasetsPage(self.page).select_testing_clinician_option( + TestingClinicianOptions.BORROWING_PROPERTY + ) + InvestigationDatasetsPage(self.page).select_aspirant_endoscopist_option( + AspirantEndoscopistOptions.ITALICISE_AMNESTY + ) + # Drug Information + InvestigationDatasetsPage(self.page).click_show_drug_information() + InvestigationDatasetsPage(self.page).select_drug_type_option1( + DrugTypeOptions.BISACODYL + ) + InvestigationDatasetsPage(self.page).fill_drug_type_dose1("10") + # Endoscopy Information + InvestigationDatasetsPage(self.page).click_show_endoscopy_information() + InvestigationDatasetsPage(self.page).check_endoscope_inserted_yes() + + def default_investigation_dataset_forms_continuation(self) -> None: + """This method fills out the second part of the default investigation dataset form.""" + DatasetFieldUtil(self.page).populate_select_locator_for_field( + "Bowel preparation quality", BowelPreparationQualityOptions.GOOD + ) + DatasetFieldUtil(self.page).populate_select_locator_for_field( + "Comfort during examination", ComfortOptions.NO_DISCOMFORT + ) + DatasetFieldUtil(self.page).populate_select_locator_for_field( + "Comfort during recovery", ComfortOptions.NO_DISCOMFORT + ) + DatasetFieldUtil(self.page).populate_select_locator_for_field( + "Endoscopist defined extent", EndoscopyLocationOptions.ILEUM + ) + DatasetFieldUtil(self.page).populate_select_locator_for_field( + "Scope imager used", YesNoOptions.YES + ) + DatasetFieldUtil(self.page).populate_select_locator_for_field( + "Retroverted view", YesNoOptions.NO + ) + DatasetFieldUtil(self.page).populate_input_locator_for_field( + "Start of intubation time", "09:00" + ) + DatasetFieldUtil(self.page).populate_input_locator_for_field( + "Start of extubation time", "09:15" + ) + DatasetFieldUtil(self.page).populate_input_locator_for_field( + "End time of procedure", "09:30" + ) + DatasetFieldUtil(self.page).populate_input_locator_for_field("Scope ID", "A1") + DatasetFieldUtil(self.page).populate_select_locator_for_field( + "Insufflation", InsufflationOptions.AIR + ) + DatasetFieldUtil(self.page).populate_select_locator_for_field( + "Outcome at time of procedure", + OutcomeAtTimeOfProcedureOptions.LEAVE_DEPARTMENT, + ) + DatasetFieldUtil(self.page).populate_select_locator_for_field( + "Late outcome", LateOutcomeOptions.NO_COMPLICATIONS + ) + InvestigationDatasetsPage(self.page).click_show_completion_proof_information() + # Completion Proof Information + DatasetFieldUtil(self.page).populate_select_locator_for_field( + "Proof Parameters", CompletionProofOptions.PHOTO_ILEO + ) + + def investigation_datasets_failure_reason(self) -> None: + """This method fills out the failure reason section of the investigation dataset form.""" + # Failure Information + InvestigationDatasetsPage(self.page).click_show_failure_information() + DatasetFieldUtil(self.page).populate_select_locator_for_field_inside_div( + "Failure Reasons", + "divFailureSection", + FailureReasonsOptions.BLEEDING_INCIDENT, + ) + + def polyps_for_high_risk_result(self) -> None: + """This method fills out the polyp information section of the investigation dataset form to trigger a high risk result.""" + # Polyp Information + InvestigationDatasetsPage(self.page).click_add_polyp_button() + DatasetFieldUtil(self.page).populate_select_locator_for_field_inside_div( + "Location", "divPolypNumber1Section", EndoscopyLocationOptions.ILEUM + ) + DatasetFieldUtil(self.page).populate_select_locator_for_field_inside_div( + "Classification", "divPolypNumber1Section", PolypClassificationOptions.LS + ) + DatasetFieldUtil(self.page).populate_input_locator_for_field_inside_div( + self.estimate_whole_polyp_size_string, "divPolypNumber1Section", "15" + ) + DatasetFieldUtil(self.page).populate_select_locator_for_field_inside_div( + self.polyp_access_string, + "divPolypNumber1Section", + PolypAccessOptions.NOT_KNOWN, + ) + self.polyp1_intervention() + InvestigationDatasetsPage(self.page).click_add_polyp_button() + DatasetFieldUtil(self.page).populate_select_locator_for_field_inside_div( + "Location", "divPolypNumber2Section", EndoscopyLocationOptions.CAECUM + ) + DatasetFieldUtil(self.page).populate_select_locator_for_field_inside_div( + "Classification", "divPolypNumber2Section", PolypClassificationOptions.LS + ) + DatasetFieldUtil(self.page).populate_input_locator_for_field_inside_div( + self.estimate_whole_polyp_size_string, "divPolypNumber2Section", "15" + ) + DatasetFieldUtil(self.page).populate_select_locator_for_field_inside_div( + self.polyp_access_string, + "divPolypNumber2Section", + PolypAccessOptions.NOT_KNOWN, + ) + InvestigationDatasetsPage(self.page).click_polyp2_add_intervention_button() + DatasetFieldUtil(self.page).populate_select_locator_for_field_inside_div( + "Modality", + "divPolypTherapy2_1Section", + PolypInterventionModalityOptions.EMR, + ) + DatasetFieldUtil(self.page).populate_select_locator_for_field_inside_div( + "Device", + "divPolypTherapy2_1Section", + PolypInterventionDeviceOptions.HOT_SNARE, + ) + DatasetFieldUtil(self.page).populate_select_locator_for_field_inside_div( + "Excised", "divPolypResected2_1", YesNoOptions.YES + ) + DatasetFieldUtil(self.page).populate_select_locator_for_field_inside_div( + "Retrieved", "divPolypTherapy2_1Section", YesNoOptions.NO + ) + DatasetFieldUtil(self.page).populate_select_locator_for_field_inside_div( + "Excision Technique", + "divPolypTherapy2_1Section", + PolypInterventionExcisionTechniqueOptions.EN_BLOC, + ) + + def polyps_for_lnpcp_result(self) -> None: + """This method fills out the polyp information section of the investigation dataset form to trigger a LNPCP result.""" + # Polyp Information + InvestigationDatasetsPage(self.page).click_add_polyp_button() + DatasetFieldUtil(self.page).populate_select_locator_for_field_inside_div( + "Location", "divPolypNumber1Section", EndoscopyLocationOptions.ILEUM + ) + DatasetFieldUtil(self.page).populate_select_locator_for_field_inside_div( + "Classification", "divPolypNumber1Section", PolypClassificationOptions.LS + ) + DatasetFieldUtil(self.page).populate_input_locator_for_field_inside_div( + self.estimate_whole_polyp_size_string, "divPolypNumber1Section", "30" + ) + DatasetFieldUtil(self.page).populate_select_locator_for_field_inside_div( + self.polyp_access_string, + "divPolypNumber1Section", + PolypAccessOptions.NOT_KNOWN, + ) + self.polyp1_intervention() + + def polyp1_intervention(self) -> None: + """This method fills out the intervention section of the investigation dataset form for polyp 1.""" + InvestigationDatasetsPage(self.page).click_polyp1_add_intervention_button() + DatasetFieldUtil(self.page).populate_select_locator_for_field_inside_div( + "Modality", + "divPolypTherapy1_1Section", + PolypInterventionModalityOptions.POLYPECTOMY, + ) + DatasetFieldUtil(self.page).populate_select_locator_for_field_inside_div( + "Device", + "divPolypTherapy1_1Section", + PolypInterventionDeviceOptions.HOT_SNARE, + ) + DatasetFieldUtil(self.page).populate_select_locator_for_field_inside_div( + "Excised", "divPolypResected1_1", YesNoOptions.YES + ) + DatasetFieldUtil(self.page).populate_select_locator_for_field_inside_div( + "Retrieved", "divPolypTherapy1_1Section", YesNoOptions.NO + ) + DatasetFieldUtil(self.page).populate_select_locator_for_field_inside_div( + "Excision Technique", + "divPolypTherapy1_1Section", + PolypInterventionExcisionTechniqueOptions.EN_BLOC, + ) + + def save_investigation_dataset(self) -> None: + """This method saves the investigation dataset form.""" + InvestigationDatasetsPage(self.page).check_dataset_complete_checkbox() + InvestigationDatasetsPage(self.page).click_save_dataset_button() + + +class AfterInvestigationDatasetComplete: + """ + This class is used to progress the episode based on the result of the investigation dataset. + It contains methods to handle the different outcomes of the investigation dataset. + """ + + def __init__(self, page: Page) -> None: + self.page = page + self.a318_latest_event_status_string = ( + "A318 - Post-investigation Appointment NOT Required - Result Letter Created" + ) + + def progress_episode_based_on_result(self, result: str, younger: bool) -> None: + """This method progresses the episode based on the result of the investigation dataset. + Args: + result (str): The result of the investigation dataset. + Should be one of InvestigationDatasetResults (HIGH_RISK, LNPCP, NORMAL). + younger (bool): True if the subject is younger than 70, False otherwise. + """ + if result == InvestigationDatasetResults.HIGH_RISK: + self.after_high_risk_result() + if younger: + self.record_diagnosis_date() + else: + self.handover_subject_to_symptomatic_care() + elif result == InvestigationDatasetResults.LNPCP: + self.after_lnpcp_result() + if younger: + self.record_diagnosis_date() + else: + self.handover_subject_to_symptomatic_care() + elif result == InvestigationDatasetResults.NORMAL: + self.after_normal_result() + self.record_diagnosis_date() + else: + logging.error("Incorrect result entered") + + def after_high_risk_result(self) -> None: + """This method advances an episode that has a high-risk result.""" + InvestigationDatasetsPage(self.page).expect_text_to_be_visible( + "High-risk findings" + ) + BasePage(self.page).click_back_button() + + # The following code is on the subject datasets page + SubjectDatasetsPage(self.page).check_investigation_dataset_complete() + BasePage(self.page).click_back_button() + + SubjectScreeningSummaryPage( + self.page + ).click_advance_fobt_screening_episode_button() + # The following code is on the advance fobt screening episode page + AdvanceFOBTScreeningEpisodePage( + self.page + ).click_enter_diagnostic_test_outcome_button() + + # The following code is on the diagnostic test outcome page + DiagnosticTestOutcomePage(self.page).verify_diagnostic_test_outcome( + "High-risk findings" + ) + DiagnosticTestOutcomePage(self.page).select_test_outcome_option( + OutcomeOfDiagnosticTest.REFER_SURVEILLANCE + ) + DiagnosticTestOutcomePage(self.page).click_save_button() + + def after_lnpcp_result(self) -> None: + """This method advances an episode that has a LNPCP result.""" + InvestigationDatasetsPage(self.page).expect_text_to_be_visible("LNPCP") + BasePage(self.page).click_back_button() + + # The following code is on the subject datasets page + SubjectDatasetsPage(self.page).check_investigation_dataset_complete() + BasePage(self.page).click_back_button() + + SubjectScreeningSummaryPage( + self.page + ).click_advance_fobt_screening_episode_button() + + # The following code is on the advance fobt screening episode page + AdvanceFOBTScreeningEpisodePage( + self.page + ).click_enter_diagnostic_test_outcome_button() + + # The following code is on the diagnostic test outcome page + DiagnosticTestOutcomePage(self.page).verify_diagnostic_test_outcome("LNPCP") + DiagnosticTestOutcomePage(self.page).select_test_outcome_option( + OutcomeOfDiagnosticTest.REFER_SURVEILLANCE + ) + DiagnosticTestOutcomePage(self.page).click_save_button() + + def after_normal_result(self) -> None: + """This method advances an episode that has a normal result.""" + InvestigationDatasetsPage(self.page).expect_text_to_be_visible( + "Normal (No Abnormalities" + ) + BasePage(self.page).click_back_button() + + # The following code is on the subject datasets page + SubjectDatasetsPage(self.page).check_investigation_dataset_complete() + BasePage(self.page).click_back_button() + + SubjectScreeningSummaryPage( + self.page + ).click_advance_fobt_screening_episode_button() + + # The following code is on the advance fobt screening episode page + AdvanceFOBTScreeningEpisodePage( + self.page + ).click_enter_diagnostic_test_outcome_button() + + # The following code is on the diagnostic test outcome page + DiagnosticTestOutcomePage(self.page).verify_diagnostic_test_outcome( + "Normal (No Abnormalities" + ) + DiagnosticTestOutcomePage(self.page).select_test_outcome_option( + OutcomeOfDiagnosticTest.INVESTIGATION_COMPLETE + ) + DiagnosticTestOutcomePage(self.page).click_save_button() + + SubjectScreeningSummaryPage(self.page).verify_latest_event_status_value( + self.a318_latest_event_status_string + ) + + def handover_subject_to_symptomatic_care(self) -> None: + """This method hands over a subject to symptomatic care.""" + SubjectScreeningSummaryPage(self.page).verify_latest_event_status_value( + "A394 - Handover into Symptomatic Care for Surveillance - Patient Age" + ) + SubjectScreeningSummaryPage( + self.page + ).click_advance_fobt_screening_episode_button() + + # The following code is on the advance fobt screening episode page + AdvanceFOBTScreeningEpisodePage( + self.page + ).click_handover_into_symptomatic_care_button() + + # The following code is on the handover into symptomatic care page + HandoverIntoSymptomaticCarePage(self.page).select_referral_dropdown_option( + "20445" + ) + HandoverIntoSymptomaticCarePage(self.page).click_calendar_button() + CalendarPicker(self.page).v1_calender_picker(datetime.today()) + HandoverIntoSymptomaticCarePage(self.page).select_consultant("201") + HandoverIntoSymptomaticCarePage(self.page).fill_notes("Test Automation") + HandoverIntoSymptomaticCarePage(self.page).click_save_button() + + SubjectScreeningSummaryPage(self.page).wait_for_page_title() + SubjectScreeningSummaryPage(self.page).verify_latest_event_status_value( + "A385 - Handover into Symptomatic Care" + ) + + def record_diagnosis_date(self) -> None: + """This method records the diagnosis date for a subject.""" + SubjectScreeningSummaryPage(self.page).verify_latest_event_status_value( + self.a318_latest_event_status_string + ) + SubjectScreeningSummaryPage( + self.page + ).click_advance_fobt_screening_episode_button() + + # The following code is on the advance fobt screening episode page + AdvanceFOBTScreeningEpisodePage(self.page).click_record_diagnosis_date_button() + + # The following code is on the record diagnosis date page + RecordDiagnosisDatePage(self.page).enter_date_in_diagnosis_date_field( + datetime.today() + ) + RecordDiagnosisDatePage(self.page).click_save_button() + + SubjectScreeningSummaryPage(self.page).verify_latest_event_status_value( + self.a318_latest_event_status_string + ) diff --git a/utils/load_properties_file.py b/utils/load_properties_file.py new file mode 100644 index 00000000..2764411d --- /dev/null +++ b/utils/load_properties_file.py @@ -0,0 +1,45 @@ +from jproperties import Properties +import os + + +class PropertiesFile: + def __init__(self): + self.smokescreen_properties_file = ( + "tests/smokescreen/bcss_smokescreen_tests.properties" + ) + self.general_properties_file = "tests/bcss_tests.properties" + + def get_properties(self, type_of_properties_file: str | None = None) -> dict: + """ + Reads the 'bcss_smokescreen_tests.properties' file or 'bcss_tests.properties' and populates a 'Properties' object depending on whether "smokescreen" is given + Returns a dictionary of properties for use in tests. + + Args: + type_of_properties_file (str): The type of properties file you want to load. e.g. 'smokescreen' or 'general' + + Returns: + dict: A dictionary containing the values loaded from the 'bcss_smokescreen_tests.properties' file. + """ + configs = Properties() + path = f"{os.getcwd()}/{self.smokescreen_properties_file if type_of_properties_file == "smokescreen" else self.general_properties_file}" + with open(path, "rb") as read_prop: + configs.load(read_prop) + return configs.properties + + def get_smokescreen_properties(self) -> dict: + """ + This is used to get the `tests/smokescreen/bcss_smokescreen_tests.properties` file + + Returns: + dict: A dictionary containing the values loaded from the 'bcss_smokescreen_tests.properties' file. + """ + return self.get_properties("smokescreen") + + def get_general_properties(self) -> dict: + """ + This is used to get the `tests/bcss_tests.properties` file + + Returns: + dict: A dictionary containing the values loaded from the 'bcss_smokescreen_tests.properties' file. + """ + return self.get_properties() diff --git a/utils/nhs_number_tools.py b/utils/nhs_number_tools.py index 2b55ae0c..8549c33b 100644 --- a/utils/nhs_number_tools.py +++ b/utils/nhs_number_tools.py @@ -1,6 +1,5 @@ import logging - logger = logging.getLogger(__name__) @@ -12,15 +11,25 @@ class NHSNumberTools: @staticmethod def _nhs_number_checks(nhs_number: str) -> None: """ - This does basic checks on NHS number values provided and raises an exception if the number is not valid. + This will validate that the provided NHS number is numeric and exactly 10 digits long. Args: - nhs_number (str): The NHS number to check. + nhs_number (str): The NHS number to validate. + + Raises: + NHSNumberToolsException: If the NHS number is not numeric or not 10 digits long. + + Returns: + None """ if not nhs_number.isnumeric(): - raise NHSNumberToolsException("The NHS number provided ({}) is not numeric.".format(nhs_number)) + raise NHSNumberToolsException( + "The NHS number provided ({}) is not numeric.".format(nhs_number) + ) if len(nhs_number) != 10: - raise NHSNumberToolsException("The NHS number provided ({}) is not 10 digits.".format(nhs_number)) + raise NHSNumberToolsException( + "The NHS number provided ({}) is not 10 digits.".format(nhs_number) + ) @staticmethod def spaced_nhs_number(nhs_number: int | str) -> str: diff --git a/utils/notify_criteria_parser.py b/utils/notify_criteria_parser.py new file mode 100644 index 00000000..462ce1b0 --- /dev/null +++ b/utils/notify_criteria_parser.py @@ -0,0 +1,20 @@ +import re + +def parse_notify_criteria(criteria: str) -> dict: + """ + Parses criteria strings like 'S1 - new' or 'S1 (S1w) - sending' into parts. + """ + criteria = criteria.strip() + if criteria.lower() == "none": + return {"status": "none"} + + pattern = r"^(?P[^\s(]+)(?:\s+\((?P[^)]+)\))?\s*-\s*(?P\w+)$" + match = re.match(pattern, criteria, re.IGNORECASE) + if not match: + raise ValueError(f"Invalid Notify criteria format: '{criteria}'") + + return { + "type": match.group("type"), + "code": match.group("code"), + "status": match.group("status").lower(), + } diff --git a/utils/oracle/oracle.py b/utils/oracle/oracle.py new file mode 100644 index 00000000..be0b4370 --- /dev/null +++ b/utils/oracle/oracle.py @@ -0,0 +1,227 @@ +import oracledb +import os +from dotenv import load_dotenv +from sqlalchemy import create_engine +import pandas as pd +import logging + + +class OracleDB: + def __init__(self): + load_dotenv() + self.user = os.getenv("ORACLE_USERNAME") + self.dns = os.getenv("ORACLE_DB") + self.password = os.getenv("ORACLE_PASS") + + def connect_to_db(self) -> oracledb.Connection: + """ + This function is used to connect to the Oracle DB. All the credentials are retrieved from a .env file + + Returns: + conn (oracledb.Connection): The Oracle DB connection object + """ + try: + logging.info("Attempting DB connection...") + conn = oracledb.connect( + user=self.user, password=self.password, dsn=self.dns + ) + logging.info("DB connection successful!") + return conn + except Exception as queryExecutionError: + raise RuntimeError(f"Database connection failed: {queryExecutionError}") + + def disconnect_from_db(self, conn: oracledb.Connection) -> None: + """ + Disconnects from the DB + + Args: + conn (oracledb.Connection): The Oracle DB connection object + """ + conn.close() + logging.info("Connection Closed") + + def exec_bcss_timed_events( + self, nhs_number_df: pd.DataFrame + ) -> None: # Executes bcss_timed_events when given NHS numbers + """ + This function is used to execute bcss_timed_events against NHS Numbers. + It expects the nhs_numbers to be in a dataframe, and runs a for loop to get the subject_screening_id for each nhs number + Once a subject_screening_id is retrieved, it will then run the command: exec bcss_timed_events [,'Y'] + + Args: + nhs_number_df (pd.DataFrame): A dataframe containing all of the NHS numbers as separate rows + """ + conn = self.connect_to_db() + try: + for index, row in nhs_number_df.iterrows(): + subject_id = self.get_subject_id_from_nhs_number( + row["subject_nhs_number"] + ) + try: + logging.info( + f"Attempting to execute stored procedure: {f"'bcss_timed_events', [{subject_id},'Y']"}" + ) + cursor = conn.cursor() + cursor.callproc("bcss_timed_events", [subject_id, "Y"]) + logging.info("Stored procedure execution successful!") + except Exception as spExecutionError: + logging.error( + f"Failed to execute stored procedure with execution error: {spExecutionError}" + ) + except Exception as queryExecutionError: + logging.error( + f"Failed to to extract subject ID with error: {queryExecutionError}" + ) + finally: + if conn is not None: + self.disconnect_from_db(conn) + + def get_subject_id_from_nhs_number(self, nhs_number: str) -> str: + """ + This function is used to obtain the subject_screening_id of a subject when given an nhs number + + Args: + nhs_number (str): The NHS number of the subject + + Returns: + subject_id (str): The subject id for the provided nhs number + """ + conn = self.connect_to_db() + logging.info(f"Attempting to get subject_id from nhs number: {nhs_number}") + cursor = conn.cursor() + cursor.execute( + f"SELECT SCREENING_SUBJECT_ID FROM SCREENING_SUBJECT_T WHERE SUBJECT_NHS_NUMBER = {int(nhs_number)}" + ) + result = cursor.fetchall() + subject_id = result[0][0] + logging.info(f"Able to extract subject ID: {subject_id}") + return subject_id + + def populate_ui_approved_users_table( + self, user: str + ) -> None: # To add users to the UI_APPROVED_USERS table + """ + This function is used to add a user to the UI_APPROVED_USERS table + + Args: + user (str): The user you want to add to the table + """ + conn = self.connect_to_db() + try: + logging.info("Attempting to write to the db...") + cursor = conn.cursor() + cursor.execute( + f"INSERT INTO UI_APPROVED_USERS (OE_USER_CODE) VALUES ('{user}')" + ) + conn.commit() + logging.info("DB write successful!") + except Exception as dbWriteError: + logging.error(f"Failed to write to the DB! with write error {dbWriteError}") + finally: + if conn is not None: + self.disconnect_from_db(conn) + + def delete_all_users_from_approved_users_table( + self, + ) -> None: # To remove all users from the UI_APPROVED_USERS table + """ + This function is used to remove users from the UI_APPROVED_USERS table where OE_USER_CODE is not null + """ + conn = self.connect_to_db() + try: + logging.info("Attempting to delete users from DB table...") + cursor = conn.cursor() + cursor.execute( + "DELETE FROM UI_APPROVED_USERS WHERE OE_USER_CODE is not null" + ) + conn.commit() + logging.info("DB table values successfully deleted!") + except Exception as dbValuesDeleteError: + logging.error( + f"Failed to delete values from the DB table! with data deletion error {dbValuesDeleteError}" + ) + finally: + if conn is not None: + self.disconnect_from_db(conn) + + def execute_query( + self, query: str, parameters: dict | None = None + ) -> pd.DataFrame: # To use when "select xxxx" (stored procedures) + """ + This is used to execute any sql queries. + A query is provided and then the result is returned as a pandas dataframe + + Args: + query (str): The SQL query you wish to run + parameters (dict | None): Optional - Any parameters you want to pass on in a dictionary + + Returns: + df (pd.DataFrame): A pandas dataframe of the result of the query + """ + conn = self.connect_to_db() + engine = create_engine("oracle+oracledb://", creator=lambda: conn) + try: + df = ( + pd.read_sql(query, engine) + if parameters == None + else pd.read_sql(query, engine, params=parameters) + ) + except Exception as executionError: + logging.error( + f"Failed to execute query with execution error {executionError}" + ) + finally: + if conn is not None: + self.disconnect_from_db(conn) + return df + + def execute_stored_procedure( + self, procedure: str + ) -> None: # To use when "exec xxxx" (stored procedures) + """ + This is to be used whenever we need to execute a stored procedure. + It is provided with the stored procedure name and then executes it + + Args: + procedure (str): The stored procedure you want to run + """ + conn = self.connect_to_db() + try: + logging.info(f"Attempting to execute stored procedure: {procedure}") + cursor = conn.cursor() + cursor.callproc(procedure) + conn.commit() + logging.info("stored procedure execution successful!") + except Exception as executionError: + logging.error( + f"Failed to execute stored procedure with execution error: {executionError}" + ) + finally: + if conn is not None: + self.disconnect_from_db(conn) + + def update_or_insert_data_to_table( + self, statement: str, params: dict + ) -> None: # To update or insert data into a table + """ + This is used to update or insert data into a table. + It is provided with the SQL statement along with the arguments + + Args: + statement (str): The SQL query you wish to run + params (list | None): Any parameters you want to pass on in a list + """ + conn = self.connect_to_db() + try: + logging.info("Attempting to insert/update table") + cursor = conn.cursor() + cursor.execute(statement, params) + conn.commit() + logging.info("DB table successfully updated!") + except Exception as dbUpdateInsertError: + logging.error( + f"Failed to insert/update values from the DB table! with error {dbUpdateInsertError}" + ) + finally: + if conn is not None: + self.disconnect_from_db(conn) diff --git a/utils/oracle/oracle_specific_functions.py b/utils/oracle/oracle_specific_functions.py new file mode 100644 index 00000000..3810f2ae --- /dev/null +++ b/utils/oracle/oracle_specific_functions.py @@ -0,0 +1,571 @@ +from oracle.oracle import OracleDB +import logging +import pandas as pd +from datetime import datetime +from enum import IntEnum + + +class SqlQueryValues(IntEnum): + S10_EVENT_STATUS = 11198 + S19_EVENT_STATUS = 11213 + S43_EVENT_STATUS = 11223 + OPEN_EPISODE_STATUS_ID = 11352 + A8_EVENT_STATUS = 11132 + POSITIVE_APPOINTMENT_BOOKED = 11119 + POST_INVESTIGATION_APPOINTMENT_NOT_REQUIRED = 160182 + + +def get_kit_id_from_db( + tk_type_id: int, hub_id: int, no_of_kits_to_retrieve: int +) -> pd.DataFrame: + """ + This query is used to obtain test kits used in compartment 2 + + Args: + tk_type_id (int): The id of the kit type + hub_id (int): The hub ID of the screening center + no_of_kits_to_retrieve (int): The amount of kits you want to retrieve + + Returns: + kit_id_df (pd.DataFrame): A pandas DataFrame containing the result of the query + """ + logging.info("Retrieving useable test kit ids") + query = """select tk.kitid, tk.screening_subject_id, sst.subject_nhs_number + from tk_items_t tk + inner join ep_subject_episode_t se on se.screening_subject_id = tk.screening_subject_id + inner join screening_subject_t sst on (sst.screening_subject_id = tk.screening_subject_id) + inner join sd_contact_t sdc on (sdc.nhs_number = sst.subject_nhs_number) + where tk.tk_type_id = :tk_type_id + and tk.logged_in_flag = 'N' + and sdc.hub_id = :hub_id + and device_id is null + and tk.invalidated_date is null + and se.latest_event_status_id in (:s10_event_status, :s19_event_status) + order by tk.kitid DESC + fetch first :subjects_to_retrieve rows only""" + + params = { + "s10_event_status": SqlQueryValues.S10_EVENT_STATUS, + "s19_event_status": SqlQueryValues.S19_EVENT_STATUS, + "tk_type_id": tk_type_id, + "hub_id": hub_id, + "subjects_to_retrieve": no_of_kits_to_retrieve, + } + + kit_id_df = OracleDB().execute_query(query, params) + + return kit_id_df + + +def get_nhs_no_from_batch_id(batch_id: str) -> pd.DataFrame: + """ + This query returns a dataframe of NHS Numbers of the subjects in a certain batch + We provide the batch ID e.g. 8812 and then we have a list of NHS Numbers we can verify the statuses + + Args: + batch_id (str): The batch ID you want to get the subjects from + + Returns: + nhs_number_df (pd.DataFrame): A pandas DataFrame containing the result of the query + """ + nhs_number_df = OracleDB().execute_query( + f""" + SELECT SUBJECT_NHS_NUMBER + FROM SCREENING_SUBJECT_T ss + INNER JOIN sd_contact_t c ON ss.subject_nhs_number = c.nhs_number + INNER JOIN LETT_BATCH_RECORDS lbr + ON ss.SCREENING_SUBJECT_ID = lbr.SCREENING_SUBJECT_ID + WHERE lbr.BATCH_ID IN {batch_id} + AND ss.screening_status_id != 4008 + ORDER BY ss.subject_nhs_number + """ + ) + return nhs_number_df + + +def get_kit_id_logged_from_db(smokescreen_properties: dict) -> pd.DataFrame: + """ + This query is used to obtain test data used in compartment 3 + + Args: + smokescreen_properties(): A dictionary containing all values needed to run the query + + Returns: + return kit_id_df (pd.DataFrame): A pandas DataFrame containing the result of the query + """ + query = """SELECT tk.kitid,tk.device_id,tk.screening_subject_id + FROM tk_items_t tk + INNER JOIN kit_queue kq ON kq.device_id = tk.device_id + INNER JOIN ep_subject_episode_t se ON se.screening_subject_id = tk.screening_subject_id + WHERE tk.logged_in_flag = 'Y' + AND kq.test_kit_status IN ('LOGGED', 'POSTED') + AND se.episode_status_id = :open_episode_status_id + AND tk.tk_type_id = 2 + AND se.latest_event_status_id = :s43_event_status + AND tk.logged_in_at = :logged_in_at + AND tk.reading_flag = 'N' + AND tk.test_results IS NULL + fetch first :subjects_to_retrieve rows only + """ + + params = { + "s43_event_status": SqlQueryValues.S43_EVENT_STATUS, + "logged_in_at": smokescreen_properties["c3_fit_kit_results_test_org_id"], + "open_episode_status_id": SqlQueryValues.OPEN_EPISODE_STATUS_ID, + "subjects_to_retrieve": smokescreen_properties["c3_total_fit_kits_to_retrieve"], + } + + kit_id_df = OracleDB().execute_query(query, params) + + return kit_id_df + + +def get_service_management_by_device_id(device_id: str) -> pd.DataFrame: + """ + This SQL is similar to the one used in pkg_test_kit_queue.p_get_fit_monitor_details, but adapted to allow us to pick out sub-sets of records + + Args: + device_id (str): The device ID + + Returns: + get_service_management_df (pd.DataFrame): A pandas DataFrame containing the result of the query + """ + + query = """SELECT kq.device_id, kq.test_kit_name, kq.test_kit_type, kq.test_kit_status, + CASE WHEN tki.logged_in_flag = 'Y' THEN kq.logged_by_hub END AS logged_by_hub, + CASE WHEN tki.logged_in_flag = 'Y' THEN kq.date_time_logged END AS date_time_logged, + tki.logged_in_on AS tk_logged_date_time, kq.test_result, kq.calculated_result, + kq.error_code, + (SELECT vvt.description + FROM tk_analyser_t tka + INNER JOIN tk_analyser_type_error tkate ON tkate.tk_analyser_type_id = tka.tk_analyser_type_id + INNER JOIN valid_values vvt ON tkate.tk_analyser_error_type_id = vvt.valid_value_id + WHERE tka.analyser_code = kq.analyser_code AND tkate.error_code = kq.error_code) + AS analyser_error_description, kq.analyser_code, kq.date_time_authorised, + kq.authoriser_user_code, kq.datestamp, kq.bcss_error_id, + REPLACE(mt.description, 'ERROR - ', '') AS error_type, + NVL(mta.allowed_value, 'N') AS error_ok_to_archive, + kq.post_response, kq.post_attempts, kq.put_response, + kq.put_attempts, kq.date_time_error_archived, + kq.error_archived_user_code, sst.screening_subject_id, + sst.subject_nhs_number, tki.test_results, tki.issue_date, + o.org_code AS issued_by_hub + FROM kit_queue kq + LEFT OUTER JOIN tk_items_t tki ON tki.device_id = kq.device_id + OR (tki.device_id IS NULL AND tki.kitid = pkg_test_kit.f_get_kit_id_from_device_id(kq.device_id)) + LEFT OUTER JOIN screening_subject_t sst ON sst.screening_subject_id = tki.screening_subject_id + LEFT OUTER JOIN ep_subject_episode_t ep ON ep.subject_epis_id = tki.subject_epis_id + LEFT OUTER JOIN message_types mt ON kq.bcss_error_id = mt.message_type_id + LEFT OUTER JOIN valid_values mta ON mta.valid_value_id = mt.message_attribute_id AND mta.valid_value_id = 305482 + LEFT OUTER JOIN ORG o ON ep.start_hub_id = o.org_id + LEFT OUTER JOIN ORG lo ON lo.org_code = kq.logged_by_hub + WHERE kq.test_kit_type = 'FIT' AND kq.device_id = :device_id + """ + params = {"device_id": device_id} + get_service_management_df = OracleDB().execute_query(query, params) + return get_service_management_df + + +def update_kit_service_management_entity( + device_id: str, normal: bool, smokescreen_properties: dict +) -> str: + """ + This method is used to update the KIT_QUEUE table on the DB + This is done so that we can then run two stored procedures to update the subjects and kits status to either normal or abnormal + + Args: + device_id (str): The device ID + normal (bool): Whether the device should be marked as normal or abnormal + smokescreen_properties(): A dictionary containing all values needed to run the query + + Returns: + subject_nhs_number (str): The NHS Number of the affected subject + """ + get_service_management_df = get_service_management_by_device_id(device_id) + + # Extract the NHS number from the DataFrame + subject_nhs_number = get_service_management_df["subject_nhs_number"].iloc[0] + test_kit_name = get_service_management_df["test_kit_name"].iloc[0] + test_kit_type = get_service_management_df["test_kit_type"].iloc[0] + logged_by_hub = get_service_management_df["logged_by_hub"].iloc[0] + date_time_logged = get_service_management_df["date_time_logged"].iloc[0] + calculated_result = get_service_management_df["calculated_result"].iloc[0] + post_response = get_service_management_df["post_response"].iloc[0] + post_attempts = get_service_management_df["post_attempts"].iloc[0] + put_response = get_service_management_df["put_response"].iloc[0] + put_attempts = get_service_management_df["put_attempts"].iloc[0] + # format date + date_time_authorised = ( + datetime.now().strftime("%d-%b-%y %H.%M.%S.") + + f"{datetime.now().microsecond:06d}000" + ) + if normal: + test_result = int(smokescreen_properties["c3_fit_kit_normal_result"]) + else: + test_result = int(smokescreen_properties["c3_fit_kit_abnormal_result"]) + # Parameterized query + update_query = """ + UPDATE kit_queue kq + SET kq.test_kit_name = :test_kit_name, + kq.test_kit_type = :test_kit_type, + kq.test_kit_status =:test_kit_status, + kq.logged_by_hub = :logged_by_hub, + kq.date_time_logged = :date_time_logged, + kq.test_result = :test_result, + kq.calculated_result = :calculated_result, + kq.error_code = NULL, + kq.analyser_code = :analyser_code, + kq.date_time_authorised = TO_TIMESTAMP(:date_time_authorised, 'DD-Mon-YY HH24.MI.SS.FF9'), + kq.authoriser_user_code = :authoriser_user_code, + kq.post_response = :post_response, + kq.post_attempts = :post_attempts, + kq.put_response = :put_response, + kq.put_attempts = :put_attempts + WHERE kq.device_id = :device_id + """ + + # Parameters dictionary + params = { + "test_kit_name": test_kit_name, + "test_kit_type": test_kit_type, + "test_kit_status": "BCSS_READY", + "logged_by_hub": logged_by_hub, + "date_time_logged": date_time_logged, + "test_result": int(test_result), + "calculated_result": calculated_result, + "analyser_code": smokescreen_properties["c3_fit_kit_analyser_code"], + "date_time_authorised": str(date_time_authorised), + "authoriser_user_code": smokescreen_properties["c3_fit_kit_authorised_user"], + "post_response": int(post_response) if post_response is not None else 0, + "post_attempts": int(post_attempts) if post_attempts is not None else 0, + "put_response": put_response, + "put_attempts": put_attempts, + "device_id": device_id, + } + + # Execute query + rows_affected = OracleDB().update_or_insert_data_to_table(update_query, params) + logging.info(f"Rows affected: {rows_affected}") + # Return the subject NHS number + return subject_nhs_number + + +def execute_fit_kit_stored_procedures() -> None: + """ + This method executes two stored procedures: + - PKG_TEST_KIT_QUEUE.p_validate_kit_queue + - PKG_TEST_KIT_QUEUE.p_calculate_result + This is needed for compartment 3 after we update the KIT_QUEUE table on the DB + """ + db_instance = OracleDB() # Create an instance of the OracleDB class + logging.info("start: oracle.OracleDB.execute_stored_procedure") + db_instance.execute_stored_procedure( + "PKG_TEST_KIT_QUEUE.p_validate_kit_queue" + ) # Run stored procedure - validate kit queue + db_instance.execute_stored_procedure( + "PKG_TEST_KIT_QUEUE.p_calculate_result" + ) # Run stored procedure - calculate result + logging.info("exit: oracle.OracleDB.execute_stored_procedure") + + +def get_subjects_for_appointments(subjects_to_retrieve: int) -> pd.DataFrame: + """ + This is used to get subjects for compartment 4 + It finds subjects with open episodes and the event status A8 + It also checks that the episode is less than 2 years old + + Args: + subjects_to_retrieve (int): The number of subjects to retrieve + + Returns: + subjects_df (pd.DataFrame): A pandas DataFrame containing the result of the query + """ + + query = """select tk.kitid, ss.subject_nhs_number, se.screening_subject_id + from tk_items_t tk + inner join ep_subject_episode_t se on se.screening_subject_id = tk.screening_subject_id + inner join screening_subject_t ss on ss.screening_subject_id = se.screening_subject_id + inner join sd_contact_t c on c.nhs_number = ss.subject_nhs_number + where se.latest_event_status_id = :a8_event_status + and tk.logged_in_flag = 'Y' + and se.episode_status_id = :open_episode_status_id + and ss.screening_status_id != 4008 + and tk.logged_in_at = 23159 + and c.hub_id = 23159 + and tk.tk_type_id = 2 + and tk.datestamp > add_months(sysdate,-24) + order by ss.subject_nhs_number desc + fetch first :subjects_to_retrieve rows only + """ + params = { + "a8_event_status": SqlQueryValues.A8_EVENT_STATUS, + "open_episode_status_id": SqlQueryValues.OPEN_EPISODE_STATUS_ID, + "subjects_to_retrieve": subjects_to_retrieve, + } + + subjects_df = OracleDB().execute_query(query, params) + + return subjects_df + + +def get_subjects_with_booked_appointments(subjects_to_retrieve: int) -> pd.DataFrame: + """ + This is used to get subjects for compartment 5 + It finds subjects with appointments book and letters sent + and makes sure appointments are prior to todays date + + Args: + subjects_to_retrieve (int): The number of subjects to retrieve + + Returns: + subjects_df (pd.DataFrame): A pandas DataFrame containing the result of the query + """ + + query = """select distinct(s.subject_nhs_number), a.appointment_date, c.person_family_name, c.person_given_name + from + (select count(*), ds.screening_subject_id + from + ( + select ep.screening_subject_id, ep.subject_epis_id + from ep_subject_episode_t ep + inner join ds_patient_assessment_t pa + on pa.episode_id = ep.subject_epis_id + ) ds + group by ds.screening_subject_id + having count(*) = 1 + ) ss + inner join tk_items_t tk on tk.screening_subject_id = ss.screening_subject_id + inner join ep_subject_episode_t se on se.screening_subject_id = tk.screening_subject_id + and se.subject_epis_id = tk.logged_subject_epis_id + inner join screening_subject_t s on s.screening_subject_id = se.screening_subject_id + inner join sd_contact_t c on c.nhs_number = s.subject_nhs_number + inner join appointment_t a on se.subject_epis_id = a.subject_epis_id + where se.latest_event_status_id = :positive_appointment_booked + and tk.logged_in_flag = 'Y' + and se.episode_status_id = :open_episode_status_id + and tk.logged_in_at = 23159 + and tk.algorithm_sc_id = 23162 + --and a.appointment_date > sysdate-27 + and a.cancel_date is null + and a.attend_info_id is null and a.attend_date is null + and a.cancel_dna_reason_id is null + and a.appointment_date <= sysdate + and tk.tk_type_id = 2 + --and tk.datestamp > add_months(sysdate,-24) + order by a.appointment_date desc + fetch first :subjects_to_retrieve rows only + """ + + params = { + "positive_appointment_booked": SqlQueryValues.POSITIVE_APPOINTMENT_BOOKED, + "open_episode_status_id": SqlQueryValues.OPEN_EPISODE_STATUS_ID, + "subjects_to_retrieve": subjects_to_retrieve, + } + + subjects_df = OracleDB().execute_query(query, params) + + return subjects_df + + +def get_subjects_for_investigation_dataset_updates( + number_of_subjects: int, hub_id: str +) -> pd.DataFrame: + """ + This is used to get subjects for compartment 6 + It finds subjects with latest envent status of A323 - Post-investigation Appointment + + Args: + number_of_subjects (int): The number of subjects to retrieve + hub_id (str): hub id to use + + Returns: + subjects_df (pd.DataFrame): A pandas DataFrame containing the result of the query + """ + + query = """SELECT distinct(ss.subject_nhs_number) + from + ( + select count(*), eset.screening_subject_id + from ep_subject_episode_t eset + inner join screening_subject_t sst on (eset.screening_subject_id = sst.screening_subject_id) + inner join external_tests_t ext on (eset.subject_epis_id = ext.subject_epis_id) + inner join ds_colonoscopy_t dct on (ext.ext_test_id = dct.ext_test_id) + where dct.dataset_completed_date is null + and dct.deleted_flag ='N' + and ext.void = 'N' + group by eset.screening_subject_id + having + count(*)= 1 ) sst + inner join ep_subject_episode_t eset on ( sst.screening_subject_id = eset.screening_subject_id ) + inner join screening_subject_t ss on (eset.screening_subject_id = ss.screening_subject_id) + inner join sd_contact_t c ON ss.subject_nhs_number = c.nhs_number + inner join EXTERNAL_TESTS_T ext on (eset.subject_epis_id = ext.subject_epis_id) + inner join DS_COLONOSCOPY_T dct on (ext.ext_test_id = dct.ext_test_id) + inner join sd_contact_t sd on (ss.subject_nhs_number = sd.nhs_number) + inner join sd_address_t sda on (sd.contact_id = sda.address_id) + inner join org on (sd.gp_practice_id = org.org_id) + where eset.latest_event_status_id = :latest_event_status_id -- A323 - Post-investigation Appointment NOT Required + and ext.void = 'N' + and dct.dataset_new_flag = 'Y' + and dct.deleted_flag = 'N' + and sd.GP_PRACTICE_ID is not null + and eset.start_hub_id = :start_hub_id + fetch first :subjects_to_retrieve rows only + """ + + params = { + "latest_event_status_id": SqlQueryValues.POST_INVESTIGATION_APPOINTMENT_NOT_REQUIRED, + "start_hub_id": hub_id, + "subjects_to_retrieve": number_of_subjects, + } + + subjects_df = OracleDB().execute_query(query, params) + return subjects_df + + +def get_subjects_by_note_count( + type_id: int, note_status: int, note_count: int = 0 +) -> pd.DataFrame: + """ + Retrieves subjects based on the number of additional care notes of the specified type. + + Args: + type_id (int): The type ID of the additional care note to check for. + note_count (int): The number of notes to check for. Defaults to 0. + note_status (int): The status ID of the notes. + Returns: + pd.DataFrame: A pandas DataFrame containing the result of the query. + """ + query = """ + SELECT ss.screening_subject_id, ss.subject_nhs_number, :type_id AS type_id, :status_id AS note_status + FROM screening_subject_t ss + INNER JOIN sd_contact_t c ON ss.subject_nhs_number = c.nhs_number + WHERE ( + (:note_count = 0 AND NOT EXISTS ( + SELECT 1 + FROM supporting_notes_t sn + WHERE sn.screening_subject_id = ss.screening_subject_id + AND (sn.type_id = :type_id OR sn.promote_pio_id IS NOT NULL) + AND sn.status_id = :status_id + )) + OR + (:note_count > 0 AND :note_count = ( + SELECT COUNT(sn.screening_subject_id) + FROM supporting_notes_t sn + WHERE sn.screening_subject_id = ss.screening_subject_id + AND sn.type_id = :type_id + AND sn.status_id = :status_id + GROUP BY sn.screening_subject_id + )) + ) + AND ss.number_of_invitations > 0 + AND ROWNUM = 1 + """ + + params = {"type_id": type_id, "note_count": note_count, "status_id": note_status} + subjects_df = OracleDB().execute_query(query, params) + + return subjects_df + + +def get_supporting_notes( + screening_subject_id: int, type_id: int, note_status: int +) -> pd.DataFrame: + """ + Retrieves supporting notes for a given screening subject ID and type ID. + + Args: + screening_subject_id (int): The ID of the screening subject. + type_id (int): The type ID of the supporting note. + + Returns: + pd.DataFrame: A pandas DataFrame containing the result of the query. + """ + query = """ + SELECT * + FROM supporting_notes_t sn + WHERE sn.screening_subject_id = :screening_subject_id + AND sn.type_id = :type_id + AND sn.status_id = :note_status + ORDER BY NVL(sn.updated_datestamp, sn.created_datestamp) DESC + """ + params = { + "screening_subject_id": screening_subject_id, + "type_id": type_id, + "note_status": note_status, + } + notes_df = OracleDB().execute_query(query, params) + return notes_df + + +def get_subjects_with_multiple_notes(note_type: int) -> pd.DataFrame: + """ + Retrieves subjects with a multiple note counts for a specific note type and status. + + Args: + note_type (int): The type ID of the note. + note_status (int): The status ID of the note. + + Returns: + pd.DataFrame: A pandas DataFrame containing the result of the query. + """ + query = """ + SELECT ss.* + FROM screening_subject_t ss + INNER JOIN sd_contact_t c ON ss.subject_nhs_number = c.nhs_number + WHERE 1 < ( + SELECT COUNT(sn.screening_subject_id) + FROM supporting_notes_t sn + WHERE sn.screening_subject_id = ss.screening_subject_id + AND sn.type_id = :note_type + AND sn.status_id = 4100 + GROUP BY sn.screening_subject_id + ) + AND 200 > (SELECT COUNT(sn.screening_subject_id) + FROM supporting_notes_t sn + WHERE sn.screening_subject_id = ss.screening_subject_id + AND sn.type_id = :note_type + AND sn.status_id = 4100 + GROUP BY sn.screening_subject_id + ) + AND ROWNUM = 1 + """ + + params = {"note_type": note_type} + subjects_df = OracleDB().execute_query(query, params) + + return subjects_df + + +def check_if_subject_has_temporary_address(nhs_no: str) -> pd.DataFrame: + """ + Checks if the subject has a temporary address in the database. + Args: + nhs_no (str): The NHS number of the subject. + This function queries the database to determine if the subject has a temporary address + and asserts the result against the expected value. + The query checks for the existence of a temporary address by looking for a record + in the screening_subject_t, sd_contact_t, and sd_address_t tables where the address + type is 13043 (temporary address) and the effective_from date is not null. + """ + + query = """ + SELECT + CASE + WHEN EXISTS ( + SELECT 1 + FROM screening_subject_t ss + INNER JOIN sd_contact_t c ON c.nhs_number = ss.subject_nhs_number + INNER JOIN sd_address_t a ON a.contact_id = c.contact_id + WHERE ss.subject_nhs_number = :nhs_no + AND a.address_type = 13043 + AND a.effective_from IS NOT NULL + ) + THEN 'Subject has a temporary address' + ELSE 'Subject doesn''t have a temporary address' + END AS address_status + FROM DUAL + """ + bind_vars = {"nhs_no": nhs_no} + df = OracleDB().execute_query(query, bind_vars) + return df diff --git a/utils/oracle/subject_selection_query_builder.py b/utils/oracle/subject_selection_query_builder.py new file mode 100644 index 00000000..eb64e250 --- /dev/null +++ b/utils/oracle/subject_selection_query_builder.py @@ -0,0 +1,3666 @@ +from typing import Dict, Optional +import logging +from datetime import datetime, date +from utils.notify_criteria_parser import parse_notify_criteria +from classes.bowel_scope_dd_reason_for_change_type import ( + BowelScopeDDReasonForChangeType, +) +from classes.ceased_confirmation_details import CeasedConfirmationDetails +from classes.ceased_confirmation_user_id import CeasedConfirmationUserId +from classes.clinical_cease_reason_type import ClinicalCeaseReasonType +from classes.date_description import DateDescription +from classes.event_status_type import EventStatusType +from classes.episode_type import EpisodeType +from classes.has_gp_practice import HasGPPractice +from classes.has_unprocessed_sspi_updates import HasUnprocessedSSPIUpdates +from classes.has_user_dob_update import HasUserDobUpdate +from classes.subject_has_episode import SubjectHasEpisode +from classes.manual_cease_requested import ManualCeaseRequested +from classes.screening_status_type import ScreeningStatusType +from classes.sdd_reason_for_change_type import SDDReasonForChangeType +from classes.ssdd_reason_for_change_type import SSDDReasonForChangeType +from classes.ss_reason_for_change_type import SSReasonForChangeType +from classes.subject_hub_code import SubjectHubCode +from classes.subject_screening_centre_code import SubjectScreeningCentreCode +from classes.subject_selection_criteria_key import SubjectSelectionCriteriaKey +from classes.subject import Subject +from classes.user import User +from classes.selection_builder_exception import SelectionBuilderException +from classes.appointments_slot_type import AppointmentSlotType +from classes.appointment_status_type import AppointmentStatusType +from classes.which_diagnostic_test import WhichDiagnosticTest +from classes.diagnostic_test_type import DiagnosticTestType +from classes.diagnostic_test_is_void import DiagnosticTestIsVoid +from classes.diagnostic_test_has_result import DiagnosticTestHasResult +from classes.diagnostic_test_has_outcome_of_result import ( + DiagnosticTestHasOutcomeOfResult, +) +from classes.intended_extent_type import IntendedExtentType +from classes.latest_episode_has_dataset import LatestEpisodeHasDataset +from classes.latest_episode_latest_investigation_dataset import ( + LatestEpisodeLatestInvestigationDataset, +) +from classes.surveillance_review_status_type import SurveillanceReviewStatusType +from classes.does_subject_have_surveillance_review_case import ( + DoesSubjectHaveSurveillanceReviewCase, +) +from classes.surveillance_review_case_type import SurveillanceReviewCaseType +from classes.has_date_of_death_removal import HasDateOfDeathRemoval +from classes.invited_since_age_extension import InvitedSinceAgeExtension +from classes.episode_result_type import EpisodeResultType +from classes.symptomatic_procedure_result_type import SymptomaticProcedureResultType +from classes.screening_referral_type import ScreeningReferralType +from classes.lynch_due_date_reason_type import LynchDueDateReasonType +from classes.lynch_incident_episode_type import ( + LynchIncidentEpisodeType, +) +from classes.prevalent_incident_status_type import PrevalentIncidentStatusType +from classes.notify_event_status import NotifyEventStatus +from classes.yes_no_type import YesNoType + + +class SubjectSelectionQueryBuilder: + """ + Builds dynamic SQL queries for selecting screening subjects based on various criteria. + """ + + # -- SQL Fragments -- + _SQL_IS_NULL = " IS NULL " + _SQL_IS_NOT_NULL = " IS NOT NULL " + _SQL_NOT_EXISTS = " NOT EXISTS " + _SQL_AND_NOT_EXISTS = "AND NOT EXISTS" + _SQL_AND_EXISTS = "AND EXISTS" + _TRUNC_SYSDATE = "TRUNC(SYSDATE)" + + # -- Semantic Messages -- + _REASON_NO_EXISTING_SUBJECT = "no existing subject" + + def __init__(self): + """ + Initialise the query builder with empty SQL clause lists and bind variable dictionary. + """ + self.sql_select = [] + self.sql_from = [] + self.sql_where = [] + self.sql_from_episode = [] + self.sql_from_genetic_condition_diagnosis = [] + self.sql_from_cancer_audit_datasets = [] + self.bind_vars = {} + self.criteria_value_count = 0 + + self.xt = "xt" + self.ap = "ap" + + # Repeated Strings: + self.c_dob = "c.date_of_birth" + + def build_subject_selection_query( + self, + criteria: Dict[str, str], + user: "User", + subject: "Subject", + subjects_to_retrieve: Optional[int] = None, + ) -> tuple[str, dict]: + # Clear previous state to avoid duplicate SQL fragments + self.sql_select = [] + self.sql_from = [] + self.sql_where = [] + self.sql_from_episode = [] + self.sql_from_genetic_condition_diagnosis = [] + self.sql_from_cancer_audit_datasets = [] + self.bind_vars = {} + self.criteria_value_count = 0 + self.xt = "xt" + self.ap = "ap" + + self._build_select_clause() + self._build_main_from_clause() + self._start_where_clause() + self._add_variable_selection_criteria(criteria, user, subject) + if subjects_to_retrieve is not None: + self._end_where_clause(subjects_to_retrieve) + else: + self._end_where_clause(1) + + query = " ".join( + str(part) + for part in ( + self.sql_select + + self.sql_from + + self.sql_from_episode + + self.sql_from_genetic_condition_diagnosis + + self.sql_from_cancer_audit_datasets + + self.sql_where + ) + ) + logging.info("Final query: %s", query) + return query, self.bind_vars + + def _build_select_clause(self) -> None: + columns: list[str] = [ + "ss.screening_subject_id", + "ss.subject_nhs_number", + "c.person_family_name", + "c.person_given_name", + "ss.datestamp", + "ss.screening_status_id", + "ss.ss_reason_for_change_id", + "ss.screening_status_change_date", + "ss.screening_due_date", + "ss.sdd_reason_for_change_id", + "ss.sdd_change_date", + "ss.calculated_sdd", + "ss.surveillance_screen_due_date", + "ss.calculated_ssdd", + "ss.surveillance_sdd_rsn_change_id", + "ss.surveillance_sdd_change_date", + "ss.lynch_screening_due_date", + "ss.lynch_sdd_reason_for_change_id", + "ss.lynch_sdd_change_date", + "ss.lynch_calculated_sdd", + self.c_dob, + "c.date_of_death ", + ] + self.sql_select.append("SELECT " + ", ".join(columns)) + + def _build_main_from_clause(self) -> None: + self.sql_from.append( + " FROM screening_subject_t ss " + " INNER JOIN sd_contact_t c ON c.nhs_number = ss.subject_nhs_number " + ) + + def _start_where_clause(self) -> None: + self.sql_where.append(" WHERE 1=1 ") + + def _end_where_clause(self, subject_count: int) -> None: + self.sql_where.append(f" FETCH FIRST {subject_count} ROWS ONLY ") + + def _preprocess_criteria(self, key: str, value: str, subject: "Subject") -> bool: + """ + Parses and validates a single selection criterion key-value pair before dispatching. + + This method extracts and sets internal state needed to evaluate a subject selection criterion, + including the criteria key name, modifier flags, value, and comparator. It also performs initial + checks such as: + - Ignoring commented-out criteria (those starting with "#") + - Rejecting "unchanged" values if no subject is supplied + - Preventing invalid use of "not" modifiers with NULL values + + Parameters: + key (str): The criteria key name (e.g. "subject age", "screening status") + value (str): The corresponding value, possibly with modifiers or comparators + subject (Subject): The subject context to validate certain special-case logic + + Returns: + bool: True if the criteria should be processed further; False if it is ignorable + + Raises: + ValueError: If the criterion references 'unchanged' without a subject + SelectionBuilderException: For invalid usage of 'not' modifier with NULL + """ + self.criteria_key_name = key.lower() + self.criteria_has_not_modifier = self._get_criteria_has_not_comparator(value) + self.criteria_value = self._get_criteria_value(value) + self.criteria_comparator = self._get_criteria_comparator() + + if self.criteria_value.startswith("#"): + self.criteria_value = "" + + if not self.criteria_value: + return False + + if subject is None and self.criteria_value.lower().startswith("unchanged"): + raise ValueError(f"{self.criteria_key_name}: No existing subject") + + if self.criteria_value.lower() == "null" and self.criteria_has_not_modifier: + self._force_not_modifier_is_invalid_for_criteria_value() + + return True + + def _add_variable_selection_criteria( + self, + criteria: Dict[str, str], + user: "User", + subject: "Subject", + ): + for criterium_key, criterium_value in criteria.items(): + if not self._preprocess_criteria(criterium_key, criterium_value, subject): + continue + + try: + self.criteria_key = SubjectSelectionCriteriaKey.by_description( + self.criteria_key_name.replace("+", "") + ) + if self.criteria_key is None: + raise ValueError( + f"No SubjectSelectionCriteriaKey found for description: {self.criteria_key_name}" + ) + + self._check_if_more_than_one_criteria_value_is_valid_for_criteria_key() + self._check_if_not_modifier_is_valid_for_criteria_key() + self._dispatch_criteria_key(user, subject) + + except Exception: + raise SelectionBuilderException( + f"Invalid subject selection criteria key: {self.criteria_key_name}" + ) + + def _dispatch_criteria_key(self, user: "User", subject: "Subject") -> None: + """ + Executes the appropriate SQL clause logic based on the resolved SubjectSelectionCriteriaKey. + + This method is called after all preprocessing and validation of a given criterion. It routes + the request to the correct `_add_criteria_*` or `_add_join_*` method based on the value of + `self.criteria_key`. All matching logic is encapsulated here, grouped by category for clarity. + + Parameters: + user (User): The current user performing the subject selection. + subject (Subject): The subject object used for validations and lookups. + + Raises: + SelectionBuilderException: If the criteria key is invalid or unrecognized. + """ + match self.criteria_key: + # ------------------------------------------------------------------------ + # 👤 Demographics & Subject Identity Criteria + # ------------------------------------------------------------------------ + case SubjectSelectionCriteriaKey.NHS_NUMBER: + self.criteria_value.replace(" ", "") + self._add_criteria_nhs_number() + case ( + SubjectSelectionCriteriaKey.SUBJECT_AGE + | SubjectSelectionCriteriaKey.SUBJECT_AGE_YD + ): + self._add_criteria_subject_age() + case SubjectSelectionCriteriaKey.SUBJECT_HUB_CODE: + self._add_criteria_subject_hub_code(user) + case SubjectSelectionCriteriaKey.DEMOGRAPHICS_TEMPORARY_ADDRESS: + self._add_criteria_has_temporary_address() + # ------------------------------------------------------------------------ + # 🏥 Screening Centre & GP Linkage Criteria + # ------------------------------------------------------------------------ + case SubjectSelectionCriteriaKey.RESPONSIBLE_SCREENING_CENTRE_CODE: + self._add_criteria_subject_screening_centre_code(user) + case SubjectSelectionCriteriaKey.HAS_GP_PRACTICE: + self._add_criteria_has_gp_practice() + case ( + SubjectSelectionCriteriaKey.HAS_GP_PRACTICE_ASSOCIATED_WITH_SCREENING_CENTRE_CODE + ): + self._add_criteria_has_gp_practice_linked_to_sc() + # ------------------------------------------------------------------------ + # 🩺 Screening Status & Change History Criteria + # ------------------------------------------------------------------------ + case SubjectSelectionCriteriaKey.SCREENING_STATUS: + self._add_criteria_screening_status(subject) + case SubjectSelectionCriteriaKey.PREVIOUS_SCREENING_STATUS: + self._add_criteria_previous_screening_status() + case SubjectSelectionCriteriaKey.SCREENING_STATUS_REASON: + self._add_criteria_screening_status_reason(subject) + case SubjectSelectionCriteriaKey.SCREENING_STATUS_DATE_OF_CHANGE: + self._add_criteria_date_field( + subject, "ALL_PATHWAYS", "SCREENING_STATUS_CHANGE_DATE" + ) + # ------------------------------------------------------------------------ + # ⏰ Due Dates: Screening, Surveillance & Lynch Pathways + # ------------------------------------------------------------------------ + case SubjectSelectionCriteriaKey.PREVIOUS_LYNCH_DUE_DATE: + self._add_criteria_date_field(subject, "LYNCH", "PREVIOUS_DUE_DATE") + case ( + SubjectSelectionCriteriaKey.PREVIOUS_SCREENING_DUE_DATE + | SubjectSelectionCriteriaKey.PREVIOUS_SCREENING_DUE_DATE_BIRTHDAY + ): + self._add_criteria_date_field(subject, "FOBT", "PREVIOUS_DUE_DATE") + case SubjectSelectionCriteriaKey.PREVIOUS_SURVEILLANCE_DUE_DATE: + self._add_criteria_date_field( + subject, "SURVEILLANCE", "PREVIOUS_DUE_DATE" + ) + case ( + SubjectSelectionCriteriaKey.SCREENING_DUE_DATE + | SubjectSelectionCriteriaKey.SCREENING_DUE_DATE_BIRTHDAY + ): + self._add_criteria_date_field(subject, "FOBT", "DUE_DATE") + case ( + SubjectSelectionCriteriaKey.CALCULATED_SCREENING_DUE_DATE + | SubjectSelectionCriteriaKey.CALCULATED_SCREENING_DUE_DATE_BIRTHDAY + | SubjectSelectionCriteriaKey.CALCULATED_FOBT_DUE_DATE + ): + self._add_criteria_date_field(subject, "FOBT", "CALCULATED_DUE_DATE") + case SubjectSelectionCriteriaKey.SCREENING_DUE_DATE_REASON: + self._add_criteria_screening_due_date_reason(subject) + case SubjectSelectionCriteriaKey.SCREENING_DUE_DATE_DATE_OF_CHANGE: + self._add_criteria_date_field(subject, "FOBT", "DUE_DATE_CHANGE_DATE") + case SubjectSelectionCriteriaKey.SURVEILLANCE_DUE_DATE: + self._add_criteria_date_field(subject, "SURVEILLANCE", "DUE_DATE") + case SubjectSelectionCriteriaKey.CALCULATED_SURVEILLANCE_DUE_DATE: + self._add_criteria_date_field( + subject, "SURVEILLANCE", "CALCULATED_DUE_DATE" + ) + case SubjectSelectionCriteriaKey.SURVEILLANCE_DUE_DATE_REASON: + self._add_criteria_surveillance_due_date_reason(subject) + case SubjectSelectionCriteriaKey.SURVEILLANCE_DUE_DATE_DATE_OF_CHANGE: + self._add_criteria_date_field( + subject, "SURVEILLANCE", "DUE_DATE_CHANGE_DATE" + ) + case SubjectSelectionCriteriaKey.BOWEL_SCOPE_DUE_DATE_REASON: + self._add_criteria_bowel_scope_due_date_reason() + # ------------------------------------------------------------------------ + # ⛔ Cease & Manual Override Criteria + # ------------------------------------------------------------------------ + case SubjectSelectionCriteriaKey.MANUAL_CEASE_REQUESTED: + self._add_criteria_manual_cease_requested() + case SubjectSelectionCriteriaKey.CEASED_CONFIRMATION_DATE: + self._add_criteria_date_field( + subject, "ALL_PATHWAYS", "CEASED_CONFIRMATION_DATE" + ) + case SubjectSelectionCriteriaKey.CEASED_CONFIRMATION_DETAILS: + self._add_criteria_ceased_confirmation_details() + case SubjectSelectionCriteriaKey.CEASED_CONFIRMATION_USER_ID: + self._add_criteria_ceased_confirmation_user_id(user) + case SubjectSelectionCriteriaKey.CLINICAL_REASON_FOR_CEASE: + self._add_criteria_clinical_reason_for_cease() + # ------------------------------------------------------------------------ + # 📦 Event Status & System Update Flags + # ------------------------------------------------------------------------ + case ( + SubjectSelectionCriteriaKey.SUBJECT_HAS_EVENT_STATUS + | SubjectSelectionCriteriaKey.SUBJECT_DOES_NOT_HAVE_EVENT_STATUS + ): + self._add_criteria_subject_has_event_status() + case SubjectSelectionCriteriaKey.SUBJECT_HAS_UNPROCESSED_SSPI_UPDATES: + self._add_criteria_has_unprocessed_sspi_updates() + case SubjectSelectionCriteriaKey.SUBJECT_HAS_USER_DOB_UPDATES: + self._add_criteria_has_user_dob_update() + # ------------------------------------------------------------------------ + # 📁 Subject Has Episode & Age-Based Criteria + # ------------------------------------------------------------------------ + case ( + SubjectSelectionCriteriaKey.SUBJECT_HAS_EPISODES + | SubjectSelectionCriteriaKey.SUBJECT_HAS_AN_OPEN_EPISODE + ): + self._add_criteria_subject_has_episodes() + case SubjectSelectionCriteriaKey.SUBJECT_HAS_FOBT_EPISODES: + self._add_criteria_subject_has_episodes(EpisodeType.FOBT) + case SubjectSelectionCriteriaKey.SUBJECT_LOWER_FOBT_AGE: + self._add_criteria_subject_lower_fobt_age() + case SubjectSelectionCriteriaKey.SUBJECT_LOWER_LYNCH_AGE: + self._add_criteria_subject_lower_lynch_age() + # ------------------------------------------------------------------------ + # 🧱 Latest Episode Attributes + # ------------------------------------------------------------------------ + case SubjectSelectionCriteriaKey.LATEST_EPISODE_TYPE: + self._add_criteria_latest_episode_type() + case SubjectSelectionCriteriaKey.LATEST_EPISODE_SUB_TYPE: + self._add_criteria_latest_episode_sub_type() + case SubjectSelectionCriteriaKey.LATEST_EPISODE_STATUS: + self._add_criteria_latest_episode_status() + + case SubjectSelectionCriteriaKey.LATEST_EPISODE_STATUS_REASON: + self._add_criteria_latest_episode_status_reason() + case SubjectSelectionCriteriaKey.LATEST_EPISODE_RECALL_CALCULATION_METHOD: + self._add_criteria_latest_episode_recall_calc_method() + case SubjectSelectionCriteriaKey.LATEST_EPISODE_RECALL_EPISODE_TYPE: + self._add_criteria_latest_episode_recall_episode_type() + case SubjectSelectionCriteriaKey.LATEST_EPISODE_RECALL_SURVEILLANCE_TYPE: + self._add_criteria_latest_episode_recall_surveillance_type() + # ------------------------------------------------------------------------ + # 🎯 Event Code & Status Inclusion Criteria + # ------------------------------------------------------------------------ + case SubjectSelectionCriteriaKey.LATEST_EVENT_STATUS: + self._add_criteria_event_status("ep.latest_event_status_id") + case SubjectSelectionCriteriaKey.PRE_INTERRUPT_EVENT_STATUS: + self._add_criteria_event_status("ep.pre_interrupt_event_status_id") + case SubjectSelectionCriteriaKey.LATEST_EPISODE_INCLUDES_EVENT_CODE: + self._add_criteria_event_code_in_episode(True) + case SubjectSelectionCriteriaKey.LATEST_EPISODE_DOES_NOT_INCLUDE_EVENT_CODE: + self._add_criteria_event_code_in_episode(False) + case SubjectSelectionCriteriaKey.LATEST_EPISODE_INCLUDES_EVENT_STATUS: + self._add_criteria_event_status_in_episode(True) + case ( + SubjectSelectionCriteriaKey.LATEST_EPISODE_DOES_NOT_INCLUDE_EVENT_STATUS + ): + self._add_criteria_event_status_in_episode(False) + case SubjectSelectionCriteriaKey.LATEST_EPISODE_STARTED: + self._add_criteria_date_field( + subject, "ALL_PATHWAYS", "LATEST_EPISODE_START_DATE" + ) + case SubjectSelectionCriteriaKey.LATEST_EPISODE_ENDED: + self._add_criteria_date_field( + subject, "ALL_PATHWAYS", "LATEST_EPISODE_END_DATE" + ) + case SubjectSelectionCriteriaKey.LATEST_EPISODE_KIT_CLASS: + self._add_criteria_latest_episode_kit_class() + case SubjectSelectionCriteriaKey.LATEST_EPISODE_HAS_SIGNIFICANT_KIT_RESULT: + self._add_criteria_has_significant_kit_result() + case SubjectSelectionCriteriaKey.LATEST_EPISODE_HAS_REFERRAL_DATE: + self._add_criteria_has_referral_date() + case SubjectSelectionCriteriaKey.LATEST_EPISODE_HAS_DIAGNOSIS_DATE: + self._add_criteria_has_diagnosis_date() + case SubjectSelectionCriteriaKey.SUBJECT_HAS_DIAGNOSTIC_TESTS: + self._add_criteria_has_diagnostic_test(False) + case SubjectSelectionCriteriaKey.LATEST_EPISODE_HAS_DIAGNOSTIC_TEST: + self._add_criteria_has_diagnostic_test(True) + case SubjectSelectionCriteriaKey.LATEST_EPISODE_DIAGNOSIS_DATE_REASON: + self._add_criteria_diagnosis_date_reason() + case SubjectSelectionCriteriaKey.LATEST_EPISODE_COMPLETED_SATISFACTORILY: + self._add_criteria_latest_episode_completed_satisfactorily() + case SubjectSelectionCriteriaKey.HAS_DIAGNOSTIC_TEST_CONTAINING_POLYP: + self._add_criteria_has_diagnostic_test_containing_polyp() + # ------------------------------------------------------------------------ + # 📦 Kit Metadata & Participation History + # ------------------------------------------------------------------------ + case SubjectSelectionCriteriaKey.SUBJECT_HAS_UNLOGGED_KITS: + self._add_criteria_subject_has_unlogged_kits() + case SubjectSelectionCriteriaKey.SUBJECT_HAS_LOGGED_FIT_KITS: + self._add_criteria_subject_has_logged_fit_kits() + case SubjectSelectionCriteriaKey.SUBJECT_HAS_KIT_NOTES: + self._add_criteria_subject_has_kit_notes() + case SubjectSelectionCriteriaKey.SUBJECT_HAS_LYNCH_DIAGNOSIS: + self._add_criteria_subject_has_lynch_diagnosis() + case SubjectSelectionCriteriaKey.WHICH_TEST_KIT: + self._add_join_to_test_kits() + case SubjectSelectionCriteriaKey.KIT_HAS_BEEN_READ: + self._add_criteria_kit_has_been_read() + case SubjectSelectionCriteriaKey.KIT_RESULT: + self._add_criteria_kit_result() + case SubjectSelectionCriteriaKey.KIT_HAS_ANALYSER_RESULT_CODE: + self._add_criteria_kit_has_analyser_result_code() + case SubjectSelectionCriteriaKey.WHICH_APPOINTMENT: + self._add_join_to_appointments() + case SubjectSelectionCriteriaKey.APPOINTMENT_TYPE: + self._add_criteria_appointment_type() + case SubjectSelectionCriteriaKey.APPOINTMENT_STATUS: + self._add_criteria_appointment_status() + case SubjectSelectionCriteriaKey.APPOINTMENT_DATE: + self._add_criteria_date_field( + subject, "ALL_PATHWAYS", "APPOINTMENT_DATE" + ) + case SubjectSelectionCriteriaKey.WHICH_DIAGNOSTIC_TEST: + self._add_join_to_diagnostic_tests() + case SubjectSelectionCriteriaKey.DIAGNOSTIC_TEST_CONFIRMED_TYPE: + self._add_criteria_diagnostic_test_type("confirmed") + case SubjectSelectionCriteriaKey.DIAGNOSTIC_TEST_PROPOSED_TYPE: + self._add_criteria_diagnostic_test_type("proposed") + case SubjectSelectionCriteriaKey.DIAGNOSTIC_TEST_IS_VOID: + self._add_criteria_diagnostic_test_is_void() + case SubjectSelectionCriteriaKey.DIAGNOSTIC_TEST_HAS_RESULT: + self._add_criteria_diagnostic_test_has_result() + case SubjectSelectionCriteriaKey.DIAGNOSTIC_TEST_HAS_OUTCOME: + self._add_criteria_diagnostic_test_has_outcome_of_result() + case SubjectSelectionCriteriaKey.DIAGNOSTIC_TEST_INTENDED_EXTENT: + self._add_criteria_diagnostic_test_intended_extent() + case ( + SubjectSelectionCriteriaKey.LATEST_EPISODE_HAS_CANCER_AUDIT_DATASET + | SubjectSelectionCriteriaKey.LATEST_EPISODE_HAS_COLONOSCOPY_ASSESSMENT_DATASET + | SubjectSelectionCriteriaKey.LATEST_EPISODE_HAS_MDT_DATASET + ): + self._add_criteria_latest_episode_has_dataset() + case ( + SubjectSelectionCriteriaKey.LATEST_EPISODE_LATEST_INVESTIGATION_DATASET + ): + self._add_criteria_latest_episode_latest_investigation_dataset() + case SubjectSelectionCriteriaKey.LATEST_EPISODE_DATASET_INTENDED_EXTENT: + self._add_criteria_latest_episode_intended_extent() + # ------------------------------------------------------------------------ + # 📆 Clinical Milestones, Dates & Case History + # ------------------------------------------------------------------------ + case SubjectSelectionCriteriaKey.SURVEILLANCE_REVIEW_CASE_TYPE: + self._add_criteria_surveillance_review_type() + case SubjectSelectionCriteriaKey.DATE_OF_DEATH: + self._add_criteria_date_field(subject, "ALL_PATHWAYS", "DATE_OF_DEATH") + case SubjectSelectionCriteriaKey.HAS_HAD_A_DATE_OF_DEATH_REMOVAL: + self._add_criteria_has_date_of_death_removal() + case SubjectSelectionCriteriaKey.INVITED_SINCE_AGE_EXTENSION: + self._add_criteria_invited_since_age_extension() + case SubjectSelectionCriteriaKey.NOTE_COUNT: + self._add_criteria_note_count() + case SubjectSelectionCriteriaKey.SURVEILLANCE_REVIEW_STATUS: + self._add_criteria_surveillance_review_status() + case SubjectSelectionCriteriaKey.HAS_EXISTING_SURVEILLANCE_REVIEW_CASE: + self._add_criteria_does_subject_have_surveillance_review_case() + case SubjectSelectionCriteriaKey.SUBJECT_75TH_BIRTHDAY: + self._add_criteria_date_field( + subject, "ALL_PATHWAYS", "SEVENTY_FIFTH_BIRTHDAY" + ) + # ------------------------------------------------------------------------ + # 🧪 Latest Episode Results & Symptomatic Pathway + # ------------------------------------------------------------------------ + case SubjectSelectionCriteriaKey.LATEST_EPISODE_ACCUMULATED_RESULT: + self._add_criteria_latest_episode_accumulated_episode_result() + case SubjectSelectionCriteriaKey.SYMPTOMATIC_PROCEDURE_RESULT: + self._add_criteria_symptomatic_procedure_result() + case SubjectSelectionCriteriaKey.SYMPTOMATIC_PROCEDURE_DATE: + self._add_criteria_date_field( + subject, "ALL_PATHWAYS", "SYMPTOMATIC_PROCEDURE_DATE" + ) + case SubjectSelectionCriteriaKey.DIAGNOSTIC_TEST_CONFIRMED_DATE: + self._add_criteria_date_field( + subject, + "ALL_PATHWAYS", + "DIAGNOSTIC_TEST_CONFIRMED_DATE", + ) + case SubjectSelectionCriteriaKey.SCREENING_REFERRAL_TYPE: + self._add_criteria_screening_referral_type() + # ------------------------------------------------------------------------ + # 🧬 Lynch Pathway Due Dates & Diagnosis Tracking + # ------------------------------------------------------------------------ + case SubjectSelectionCriteriaKey.CALCULATED_LYNCH_DUE_DATE: + self._add_criteria_date_field(subject, "LYNCH", "CALCULATED_DUE_DATE") + case SubjectSelectionCriteriaKey.LYNCH_DUE_DATE: + self._add_criteria_date_field(subject, "LYNCH", "DUE_DATE") + case SubjectSelectionCriteriaKey.LYNCH_DUE_DATE_REASON: + self._add_criteria_lynch_due_date_reason(subject) + case SubjectSelectionCriteriaKey.LYNCH_DUE_DATE_DATE_OF_CHANGE: + self._add_criteria_date_field(subject, "LYNCH", "DUE_DATE_CHANGE_DATE") + case SubjectSelectionCriteriaKey.LYNCH_INCIDENT_EPISODE: + self._add_criteria_lynch_incident_episode() + case SubjectSelectionCriteriaKey.LYNCH_DIAGNOSIS_DATE: + self._add_criteria_date_field(subject, "LYNCH", "DIAGNOSIS_DATE") + case SubjectSelectionCriteriaKey.LYNCH_LAST_COLONOSCOPY_DATE: + self._add_criteria_date_field(subject, "LYNCH", "LAST_COLONOSCOPY_DATE") + # ------------------------------------------------------------------------ + # 🧬 CADS Clinical Dataset Filters + # ------------------------------------------------------------------------ + case SubjectSelectionCriteriaKey.CADS_ASA_GRADE: + self._add_criteria_cads_asa_grade() + case SubjectSelectionCriteriaKey.CADS_STAGING_SCANS: + self._add_criteria_cads_staging_scans() + case SubjectSelectionCriteriaKey.CADS_TYPE_OF_SCAN: + self._add_criteria_cads_type_of_scan() + case SubjectSelectionCriteriaKey.CADS_METASTASES_PRESENT: + self._add_criteria_cads_metastases_present() + case SubjectSelectionCriteriaKey.CADS_METASTASES_LOCATION: + self._add_criteria_cads_metastases_location() + case SubjectSelectionCriteriaKey.CADS_METASTASES_OTHER_LOCATION: + self._add_criteria_cads_metastases_other_location(self.criteria_value) + case SubjectSelectionCriteriaKey.CADS_FINAL_PRE_TREATMENT_T_CATEGORY: + self._add_criteria_cads_final_pre_treatment_t_category() + case SubjectSelectionCriteriaKey.CADS_FINAL_PRE_TREATMENT_N_CATEGORY: + self._add_criteria_cads_final_pre_treatment_n_category() + case SubjectSelectionCriteriaKey.CADS_FINAL_PRETREATMENT_M_CATEGORY: + self._add_criteria_cads_final_pre_treatment_m_category() + case SubjectSelectionCriteriaKey.CADS_TREATMENT_RECEIVED: + self._add_criteria_cads_treatment_received() + case SubjectSelectionCriteriaKey.CADS_REASON_NO_TREATMENT_RECEIVED: + self._add_criteria_cads_reason_no_treatment_received() + case SubjectSelectionCriteriaKey.CADS_TUMOUR_DATE_OF_DIAGNOSIS: + self._add_criteria_date_field( + subject, "ALL_PATHWAYS", "CADS_TUMOUR_DATE_OF_DIAGNOSIS" + ) + case SubjectSelectionCriteriaKey.CADS_TUMOUR_LOCATION: + self._add_criteria_cads_tumour_location() + case ( + SubjectSelectionCriteriaKey.CADS_TUMOUR_HEIGHT_OF_TUMOUR_ABOVE_ANAL_VERGE + ): + self._add_criteria_cads_tumour_height_of_tumour_above_anal_verge() + case SubjectSelectionCriteriaKey.CADS_TUMOUR_PREVIOUSLY_EXCISED_TUMOUR: + self._add_criteria_cads_tumour_previously_excised_tumour() + case SubjectSelectionCriteriaKey.CADS_TREATMENT_START_DATE: + self._add_criteria_date_field( + subject, "ALL_PATHWAYS", "CADS_TREATMENT_START_DATE" + ) + case SubjectSelectionCriteriaKey.CADS_TREATMENT_TYPE: + self._add_criteria_cads_treatment_type() + case SubjectSelectionCriteriaKey.CADS_TREATMENT_GIVEN: + self._add_criteria_cads_treatment_given() + case SubjectSelectionCriteriaKey.CADS_CANCER_TREATMENT_INTENT: + self._add_criteria_cads_cancer_treatment_intent() + case SubjectSelectionCriteriaKey.HAS_PREVIOUSLY_HAD_CANCER: + self._add_criteria_has_previously_had_cancer() + # ------------------------------------------------------------------------ + # 🧪 Screening Flow & Pathway Classification + # ------------------------------------------------------------------------ + case SubjectSelectionCriteriaKey.FOBT_PREVALENT_INCIDENT_STATUS: + self._add_criteria_fobt_prevalent_incident_status() + # ------------------------------------------------------------------------ + # 📨 Notify Message Status Filters + # ------------------------------------------------------------------------ + case SubjectSelectionCriteriaKey.NOTIFY_QUEUED_MESSAGE_STATUS: + self._add_criteria_notify_queued_message_status() + case SubjectSelectionCriteriaKey.NOTIFY_ARCHIVED_MESSAGE_STATUS: + self._add_criteria_notify_archived_message_status() + # ------------------------------------------------------------------------ + # 🛑 Fallback: Unmatched Criteria Key + # ------------------------------------------------------------------------ + case _: + raise SelectionBuilderException( + f"Invalid subject selection criteria key: {self.criteria_key_name}" + ) + + def _get_criteria_has_not_comparator(self, original_criteria_value: str) -> bool: + return original_criteria_value.startswith("NOT:") + + def _get_criteria_value(self, original_criteria_value: str) -> str: + if self.criteria_has_not_modifier: + return original_criteria_value[4:].strip() + else: + return original_criteria_value + + def _get_criteria_comparator(self) -> str: + if self.criteria_has_not_modifier: + return " != " + else: + return " = " + + def _force_not_modifier_is_invalid_for_criteria_value(self) -> None: + if self.criteria_has_not_modifier: + raise ValueError( + f"The 'NOT:' qualifier cannot be used with criteria key: {self.criteria_key_name}, value: {self.criteria_value}" + ) + + def _check_if_more_than_one_criteria_value_is_valid_for_criteria_key(self) -> None: + if self.criteria_key is None: + raise ValueError(f"criteria_key: {self.criteria_key} is None") + if ( + not self.criteria_key.allow_more_than_one_value + and self.criteria_value_count > 1 + ): + raise ValueError( + f"It is only valid to enter one selection value for criteria key: {self.criteria_key_name}" + ) + + def _check_if_not_modifier_is_valid_for_criteria_key(self) -> None: + if self.criteria_key is None: + raise ValueError("criteria_key is None") + if not self.criteria_key.allow_not_modifier and self.criteria_has_not_modifier: + raise ValueError( + f"The 'NOT:' qualifier cannot be used with criteria key: {self.criteria_key_name}" + ) + + def _add_criteria_nhs_number(self) -> None: + self.sql_where.append(" AND c.nhs_number = :nhs_number ") + self.bind_vars["nhs_number"] = self.criteria_value + + def _add_criteria_subject_age(self) -> None: + if "y/d" in self.criteria_key_name and "/" in self.criteria_value: + age_criteria = self.criteria_value.split("/") + self.sql_where.append(" AND c.date_of_birth = ") + self.sql_where.append( + self._subtract_years_from_oracle_date( + self._TRUNC_SYSDATE, age_criteria[0] + ) + ) + self.sql_where.append(" - ") + self.sql_where.append(age_criteria[1]) + else: + self.sql_where.append( + " AND FLOOR(MONTHS_BETWEEN(TRUNC(SYSDATE), c.date_of_birth)/12) " + ) + if self.criteria_value[0] in "0123456789": + self.sql_where.append("= ") + self.sql_where.append(self.criteria_value) + + def _add_criteria_subject_lower_fobt_age(self) -> None: + """ + Adds a SQL constraint that compares a subject's lower FOBT age eligibility + using a comparator and a value (e.g. '>= 55' or '>= default'). + + If value is 'default', it's replaced with a national parameter lookup: + pkg_parameters.f_get_national_param_val(10) + """ + try: + value = self.criteria_value + comparator = self.criteria_comparator + + if value.lower() == "default": + value = "pkg_parameters.f_get_national_param_val (10)" + + self.sql_where.append( + f"AND pkg_bcss_common.f_get_ss_lower_age_limit (ss.screening_subject_id) " + f"{comparator} {value}" + ) + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_subject_lower_lynch_age(self) -> None: + """ + Adds a SQL constraint for Lynch syndrome lower-age eligibility. + + If value is 'default', it's replaced with '35'. + Uses comparator to build the WHERE clause. + """ + try: + value = self.criteria_value + comparator = self.criteria_comparator + + if value.lower() == "default": + value = "35" + + self.sql_where.append( + f"AND pkg_bcss_common.f_get_lynch_lower_age_limit (ss.screening_subject_id) " + f"{comparator} {value}" + ) + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_latest_episode_type(self) -> None: + """ + Adds a SQL condition that restricts subjects based on the episode_type_id of their latest episode. + + Translates a human-readable episode type string into an internal numeric ID. + """ + try: + value = self.criteria_value.lower() + comparator = self.criteria_comparator + + # Simulate EpisodeType enum mapping + episode_type_map = { + "referral": 1, + "invitation": 2, + "test_kit_sent": 3, + "reminder": 4, + "episode_end": 5, + # Add more mappings as needed + } + + if value not in episode_type_map: + raise ValueError(f"Unknown episode type: {value}") + + episode_type_id = episode_type_map[value] + + # Simulate the required join (docs only—no real SQL execution here) + # In real builder this would ensure join to 'latest_episode' alias (ep) + self.sql_where.append( + f"AND ep.episode_type_id {comparator} {episode_type_id}" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_latest_episode_sub_type(self) -> None: + """ + Adds a SQL condition that filters based on the episode_subtype_id of a subject's latest episode. + + Translates a human-readable episode sub-type string into an internal numeric ID. + """ + try: + value = self.criteria_value.lower() + comparator = self.criteria_comparator + + # Simulated EpisodeSubType enum mapping + episode_subtype_map = { + "routine screening": 10, + "urgent referral": 11, + "pre-assessment": 12, + "follow-up": 13, + "surveillance": 14, + # Add more mappings as needed + } + + if value not in episode_subtype_map: + raise ValueError(f"Unknown episode sub-type: {value}") + + episode_subtype_id = episode_subtype_map[value] + + # Add SQL condition using the mapped ID + self.sql_where.append( + f"AND ep.episode_subtype_id {comparator} {episode_subtype_id}" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_latest_episode_status(self) -> None: + """ + Adds a SQL condition that filters based on the episode_status_id of a subject's latest episode. + + Translates a human-readable episode status into an internal numeric ID. + """ + try: + value = self.criteria_value.lower() + comparator = self.criteria_comparator + + # Simulated EpisodeStatusType mapping + episode_status_map = { + "active": 100, + "completed": 101, + "pending": 102, + "cancelled": 103, + "invalid": 104, + # Add actual mappings as needed + } + + if value not in episode_status_map: + raise ValueError(f"Unknown episode status: {value}") + + episode_status_id = episode_status_map[value] + + self.sql_where.append( + f"AND ep.episode_status_id {comparator} {episode_status_id}" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_latest_episode_status_reason(self) -> None: + """ + Adds a SQL condition that filters based on the episode_status_reason_id of the subject's latest episode. + + Allows for explicit mapping or handling of NULL where no status reason is recorded. + """ + try: + value = self.criteria_value.lower() + + # Simulated EpisodeStatusReasonType enum + episode_status_reason_map = { + "completed screening": 200, + "no longer eligible": 201, + "deceased": 202, + "moved away": 203, + "null": None, # Special case to represent SQL IS NULL + # Extend as needed + } + + if value not in episode_status_reason_map: + raise ValueError(f"Unknown episode status reason: {value}") + + status_reason_id = episode_status_reason_map[value] + + if status_reason_id is None: + self.sql_where.append("AND ep.episode_status_reason_id IS NULL") + else: + comparator = self.criteria_comparator + self.sql_where.append( + f"AND ep.episode_status_reason_id {comparator} {status_reason_id}" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_latest_episode_recall_calc_method(self) -> None: + """ + Adds a SQL condition filtering on recall_calculation_method_id from the latest episode. + + Handles mapped descriptions or nulls for closed episodes with no recall method. + """ + try: + value = self.criteria_value.lower() + + # Simulated enum-like mapping + recall_calc_method_map = { + "standard": 300, + "accelerated": 301, + "paused": 302, + "null": None, # For episodes with no recall method + # Extend with real values as needed + } + + if value not in recall_calc_method_map: + raise ValueError(f"Unknown recall calculation method: {value}") + + method_id = recall_calc_method_map[value] + + if method_id is None: + self.sql_where.append("AND ep.recall_calculation_method_id IS NULL") + else: + comparator = self.criteria_comparator + self.sql_where.append( + f"AND ep.recall_calculation_method_id {comparator} {method_id}" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_latest_episode_recall_episode_type(self) -> None: + """ + Adds a filter for recall_episode_type_id based on the type of episode that triggered the recall. + Supports mapped descriptions and IS NULL. + """ + try: + value = self.criteria_value.lower() + + recall_episode_type_map = { + "referral": 1, + "invitation": 2, + "reminder": 3, + "episode_end": 4, + "null": None, + } + + if value not in recall_episode_type_map: + raise ValueError(f"Unknown recall episode type: {value}") + + type_id = recall_episode_type_map[value] + + if type_id is None: + self.sql_where.append("AND ep.recall_episode_type_id IS NULL") + else: + comparator = self.criteria_comparator + self.sql_where.append( + f"AND ep.recall_episode_type_id {comparator} {type_id}" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_latest_episode_recall_surveillance_type(self) -> None: + """ + Adds a filter for recall_polyp_surv_type_id based on the type of surveillance used during recall. + Supports mapped descriptions and null values. + """ + try: + value = self.criteria_value.lower() + + recall_surv_type_map = { + "routine": 500, + "enhanced": 501, + "annual": 502, + "null": None, + } + + if value not in recall_surv_type_map: + raise ValueError(f"Unknown recall surveillance type: {value}") + + surv_id = recall_surv_type_map[value] + + if surv_id is None: + self.sql_where.append("AND ep.recall_polyp_surv_type_id IS NULL") + else: + comparator = self.criteria_comparator + self.sql_where.append( + f"AND ep.recall_polyp_surv_type_id {comparator} {surv_id}" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_event_status(self, column_name: str) -> None: + """ + Filters based on the event status code found in the specified column. + + Extracts a code from the criteria value (e.g. "ES01 - Invitation Received"), + and injects its corresponding event_status_id into SQL. + """ + try: + # Extract the code (e.g. "ES01") from the first word + code = self.criteria_value.strip().split()[0].upper() + comparator = self.criteria_comparator + + # Simulated EventStatusType registry + event_status_code_map = { + "ES01": 600, + "ES02": 601, + "ES03": 602, + "ES99": 699, + # ...update with real mappings + } + + if code not in event_status_code_map: + raise ValueError(f"Unknown event status code: {code}") + + event_status_id = event_status_code_map[code] + self.sql_where.append(f"AND {column_name} {comparator} {event_status_id}") + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_event_code_in_episode(self, event_is_included: bool) -> None: + """ + Adds a filter checking whether the given event code appears in the latest episode's event list. + Uses EXISTS or NOT EXISTS depending on the flag. + """ + try: + code = self.criteria_value.strip().split()[0].upper() + + # Simulated EventCodeType registry + event_code_map = { + "EV101": 701, + "EV102": 702, + "EV900": 799, + # ...extend with real mappings + } + + if code not in event_code_map: + raise ValueError(f"Unknown event code: {code}") + + event_code_id = event_code_map[code] + + exists_clause = "EXISTS" if event_is_included else self._SQL_NOT_EXISTS + + self.sql_where.append( + f"""AND {exists_clause} ( + SELECT 'evc' + FROM ep_events_t evc + WHERE evc.event_code_id = {event_code_id} + AND evc.subject_epis_id = ep.subject_epis_id + )""" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_event_status_in_episode(self, event_is_included: bool) -> None: + """ + Adds a filter that checks whether the specified event status is present + in the latest episode. Uses EXISTS or NOT EXISTS depending on the flag. + """ + try: + code = self.criteria_value.strip().split()[0].upper() + + # Simulated EventStatusType code-to-ID map + event_status_code_map = { + "ES01": 600, + "ES02": 601, + "ES03": 602, + "ES99": 699, + # Extend with actual mappings + } + + if code not in event_status_code_map: + raise ValueError(f"Unknown event status code: {code}") + + status_id = event_status_code_map[code] + exists_clause = "EXISTS" if event_is_included else self._SQL_NOT_EXISTS + + self.sql_where.append( + f"""AND {exists_clause} ( + SELECT 'ev' + FROM ep_events_t ev + WHERE ev.event_status_id = {status_id} + AND ev.subject_epis_id = ep.subject_epis_id + )""" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_latest_episode_kit_class(self) -> None: + """ + Filters based on the test kit class of the latest episode using a nested IN clause. + Resolves from symbolic class name (e.g. 'FIT') to test class ID. + """ + try: + value = self.criteria_value.upper() + comparator = self.criteria_comparator + + # Simulated TestKitClass enum + test_kit_class_map = { + "GFOBT": 800, + "FIT": 801, + # Extend as needed + } + + if value not in test_kit_class_map: + raise ValueError(f"Unknown test kit class: {value}") + + kit_class_id = test_kit_class_map[value] + + self.sql_where.append( + f"""AND ep.tk_type_id IN ( + SELECT tkt.tk_type_id + FROM tk_type_t tkt + WHERE tkt.tk_test_class_id {comparator} {kit_class_id} + )""" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_has_significant_kit_result(self) -> None: + """ + Adds a filter to check if the latest episode has a significant kit result. + Significant values: NORMAL, ABNORMAL, WEAK_POSITIVE. + Accepts criteriaValue: "yes" or "no". + """ + try: + value = self.criteria_value.strip().lower() + + if value == "yes": + exists_clause = "EXISTS" + elif value == "no": + exists_clause = self._SQL_NOT_EXISTS + else: + raise ValueError( + f"Unknown response for significant kit result: {value}" + ) + + self.sql_where.append( + f"""AND {exists_clause} ( + SELECT 'tks' + FROM tk_items_t tks + WHERE tks.screening_subject_id = ss.screening_subject_id + AND tks.logged_subject_epis_id = ep.subject_epis_id + AND tks.test_results IN ('NORMAL', 'ABNORMAL', 'WEAK_POSITIVE') + )""" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_has_referral_date(self) -> None: + """ + Adds a filter for the presence or timing of referral_date in the latest episode. + Accepts values: yes, no, past, more_than_28_days_ago, within_the_last_28_days. + """ + try: + value = self.criteria_value.strip().lower() + + clause_map = { + "yes": "ep.referral_date IS NOT NULL", + "no": "ep.referral_date IS NULL", + "past": "ep.referral_date < trunc(sysdate)", + "more_than_28_days_ago": "(ep.referral_date + 28) < trunc(sysdate)", + "within_the_last_28_days": "(ep.referral_date + 28) > trunc(sysdate)", + } + + if value not in clause_map: + raise ValueError(f"Unknown referral date condition: {value}") + + self.sql_where.append(f"AND {clause_map[value]}") + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_has_diagnosis_date(self) -> None: + """ + Adds a filter to check if the latest episode has a diagnosis_date set, + and whether it matches the subject's date of death if specified. + Accepts values: yes, no, yes_date_of_death + """ + try: + value = self.criteria_value.strip().lower() + + if value == "yes": + self.sql_where.append("AND ep.diagnosis_date IS NOT NULL") + elif value == "no": + self.sql_where.append("AND ep.diagnosis_date IS NULL") + elif value == "yes_date_of_death": + self.sql_where.append("AND ep.diagnosis_date IS NOT NULL") + self.sql_where.append("AND ep.diagnosis_date = c.date_of_death") + else: + raise ValueError(f"Unknown condition for diagnosis date: {value}") + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_has_diagnostic_test(self, latest_episode_only: bool) -> None: + """ + Adds a filter checking if the subject has (or doesn't have) a diagnostic test. + Void tests are excluded. The `latest_episode_only` flag limits scope to the latest episode. + Accepts criteria value: "yes" or "no". + """ + try: + value = self.criteria_value.strip().lower() + + if value == "no": + prefix = "AND NOT " + elif value == "yes": + prefix = "AND " + else: + raise ValueError(f"Invalid diagnostic test condition: {value}") + + subquery = [ + "EXISTS (", + " SELECT 1", + " FROM external_tests_t lesxt", + " WHERE lesxt.screening_subject_id = ss.screening_subject_id", + " AND lesxt.void = 'N'", + ] + if latest_episode_only: + subquery.append(" AND lesxt.subject_epis_id = ep.subject_epis_id") + subquery.append(")") + + self.sql_where.append(prefix + "\n".join(subquery)) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_diagnosis_date_reason(self) -> None: + """ + Adds a filter on ep.diagnosis_date_reason_id. + Supports symbolic matches (via ID) and special values: NULL, NOT_NULL. + """ + try: + value = self.criteria_value.strip().lower() + comparator = self.criteria_comparator + + # Simulated DiagnosisDateReasonType + reason_map = { + "patient informed": 900, + "clinician notified": 901, + "screening outcome": 902, + "null": "NULL", + "not_null": "NOT NULL", + # Extend as needed + } + + if value not in reason_map: + raise ValueError(f"Unknown diagnosis date reason: {value}") + + resolved = reason_map[value] + if resolved in ("NULL", "NOT NULL"): + self.sql_where.append(f"AND ep.diagnosis_date_reason_id IS {resolved}") + else: + self.sql_where.append( + f"AND ep.diagnosis_date_reason_id {comparator} {resolved}" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_latest_episode_completed_satisfactorily(self) -> None: + """ + Adds a filter to check whether the latest episode completed satisfactorily or not. + Checks for presence/absence of interruption events or disqualifying result codes. + """ + try: + value = self.criteria_value.strip().lower() + + if value == "yes": + exists_prefix = self._SQL_AND_NOT_EXISTS + elif value == "no": + exists_prefix = self._SQL_AND_EXISTS + else: + raise ValueError(f"Invalid completion flag: {value}") + + self.sql_where.append( + f"""{exists_prefix} ( + SELECT 'ev' + FROM ep_events_t ev + WHERE ev.subject_epis_id = ep.subject_epis_id + AND ( + ev.event_status_id IN (11237, 20188) + OR ep.episode_result_id IN (605002, 605003, 605004, 605007) + ) + )""" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_has_diagnostic_test_containing_polyp(self) -> None: + """ + Adds logic to filter based on whether a diagnostic test has a recorded polyp. + 'Yes' joins polyp tables; 'No' checks for absence via NOT EXISTS. + """ + try: + value = self.criteria_value.strip().lower() + + if value == "yes": + self.sql_from.append( + "INNER JOIN external_tests_t ext ON ep.subject_epis_id = ext.subject_epis_id\n" + "INNER JOIN ds_colonoscopy_t dsc ON ext.ext_test_id = dsc.ext_test_id\n" + "INNER JOIN ds_polyp_t dst ON ext.ext_test_id = dst.ext_test_id" + ) + self.sql_where.append( + """AND ext.void = 'N'\n" "AND dst.deleted_flag = 'N'""" + ) + elif value == "no": + self.sql_where.append( + """AND NOT EXISTS ( + SELECT 'ext' + FROM external_tests_t ext + LEFT JOIN ds_polyp_t dst ON ext.ext_test_id = dst.ext_test_id + WHERE ext.subject_epis_id = ep.subject_epis_id + )""" + ) + else: + raise ValueError( + f"Unknown value for diagnostic test containing polyp: {value}" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_subject_has_unlogged_kits(self) -> None: + """ + Adds a filter to check for unlogged kits across subject history, + or scoped to the latest episode. Accepts: + - "yes" + - "yes_latest_episode" + - "no" + """ + try: + value = self.criteria_value.strip().lower() + + if value in ("yes", "yes_latest_episode"): + prefix = self._SQL_AND_EXISTS + elif value == "no": + prefix = self._SQL_AND_NOT_EXISTS + else: + raise ValueError(f"Unknown value for unlogged kits: {value}") + + subquery = [ + f"{prefix} (", + " SELECT 'tku'", + " FROM tk_items_t tku", + " WHERE tku.screening_subject_id = ss.screening_subject_id", + ] + + if value == "yes_latest_episode": + self._add_join_to_latest_episode() + subquery.append(" AND tku.subject_epis_id = ep.subject_epis_id") + + subquery.append(" AND tku.logged_in_flag = 'N'") + subquery.append(")") + + self.sql_where.append("\n".join(subquery)) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_subject_has_logged_fit_kits(self) -> None: + """ + Adds a filter to check if the subject has logged FIT kits (tk_type_id > 1 and logged_in_flag = 'Y'). + Accepts values: 'yes' or 'no'. + """ + try: + value = self.criteria_value.strip().lower() + + if value == "yes": + prefix = self._SQL_AND_EXISTS + elif value == "no": + prefix = self._SQL_AND_NOT_EXISTS + else: + raise ValueError(f"Invalid value for logged FIT kits: {value}") + + self.sql_where.append( + f"""{prefix} ( + SELECT 'tkl' + FROM tk_items_t tkl + WHERE tkl.screening_subject_id = ss.screening_subject_id + AND tkl.tk_type_id > 1 + AND tkl.logged_in_flag = 'Y' + )""" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_subject_has_kit_notes(self) -> None: + """ + Filters subjects based on presence of active kit-related notes. + Accepts values: 'yes' or 'no'. + """ + try: + value = self.criteria_value.strip().lower() + + if value == "yes": + prefix = self._SQL_AND_EXISTS + elif value == "no": + prefix = self._SQL_AND_NOT_EXISTS + else: + raise ValueError(f"Invalid value for kit notes: {value}") + + self.sql_where.append( + f"""{prefix} ( + SELECT 1 + FROM supporting_notes_t sn + WHERE sn.screening_subject_id = ss.screening_subject_id + AND ( + sn.type_id = '308015' + OR sn.promote_pio_id IS NOT NULL + ) + AND sn.status_id = 4100 + ) + AND ss.number_of_invitations > 0 + AND rownum = 1""" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_subject_has_lynch_diagnosis(self) -> None: + """ + Adds a filter to check if a subject has an active Lynch diagnosis. + Accepts: + - "yes" → subject must have active diagnosis ('Y') + - "no" → subject must not have active diagnosis ('N') + """ + try: + value = self.criteria_value.strip().lower() + + if value == "yes": + self.sql_where.append( + "AND pkg_lynch.f_subject_has_active_lynch_diagnosis (ss.screening_subject_id) = 'Y'" + ) + elif value == "no": + self.sql_where.append( + "AND pkg_lynch.f_subject_has_active_lynch_diagnosis (ss.screening_subject_id) = 'N'" + ) + else: + raise ValueError(f"Invalid value for Lynch diagnosis: {value}") + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_join_to_test_kits(self) -> None: + """ + Adds joins to the tk_items_t table based on test kit selection criteria. + Handles whether any kit is considered, or a specific one from the latest episode. + + Expected values (case-insensitive): + - "any_kit_in_any_episode" + - "only_kit_issued_in_latest_episode" + - "first_kit_issued_in_latest_episode" + - "latest_kit_issued_in_latest_episode" + - "only_kit_logged_in_latest_episode" + - "first_kit_logged_in_latest_episode" + - "latest_kit_logged_in_latest_episode" + """ + try: + value = self.criteria_value.strip().lower() + tk_alias = "tk" # You can extend this if you need multiple joins + + # Base join for all paths (only FIT kits) + self.sql_from.append( + f"INNER JOIN tk_items_t {tk_alias} ON {tk_alias}.screening_subject_id = ss.screening_subject_id " + f"AND {tk_alias}.tk_type_id > 1" + ) + + if value == "any_kit_in_any_episode": + return + + if "issued_in_latest_episode" in value: + self._add_join_to_latest_episode() + self.sql_from.append( + f"AND {tk_alias}.subject_epis_id = ep.subject_epis_id " + f"AND NOT EXISTS (" + f" SELECT 'tko1' FROM tk_items_t tko " + f" WHERE tko.screening_subject_id = ss.screening_subject_id " + f" AND tko.subject_epis_id = ep.subject_epis_id " + ) + if value.startswith("only"): + comparator = "!=" + elif value.startswith("first"): + comparator = "<" + else: # latest + comparator = ">" + self.sql_from.append(f" AND tko.kitid {comparator} {tk_alias}.kitid)") + + elif "logged_in_latest_episode" in value: + self._add_join_to_latest_episode() + self.sql_from.append( + f"AND {tk_alias}.logged_subject_epis_id = ep.subject_epis_id " + f"AND NOT EXISTS (" + f" SELECT 'tko2' FROM tk_items_t tko " + f" WHERE tko.screening_subject_id = ss.screening_subject_id " + f" AND tko.logged_subject_epis_id = ep.subject_epis_id" + ) + if value.startswith("only"): + self.sql_from.append(f" AND tko.kitid != {tk_alias}.kitid") + elif value.startswith("first"): + self.sql_from.append( + f" AND tko.logged_in_on < {tk_alias}.logged_in_on" + ) + else: # latest + self.sql_from.append( + f" AND tko.logged_in_on > {tk_alias}.logged_in_on" + ) + self.sql_from.append(")") + + else: + raise ValueError(f"Invalid test kit selection value: {value}") + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_kit_has_been_read(self) -> None: + """ + Filters test kits based on whether they have been read. + Requires prior join to tk_items_t as alias 'tk' (via WHICH_TEST_KIT). + + Accepts values: + - "yes" → reading_flag = 'Y' + - "no" → reading_flag = 'N' + """ + try: + value = self.criteria_value.strip().lower() + + if value == "yes": + self.sql_where.append("AND tk.reading_flag = 'Y'") + elif value == "no": + self.sql_where.append("AND tk.reading_flag = 'N'") + else: + raise ValueError(f"Invalid value for kit has been read: {value}") + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_kit_result(self) -> None: + """ + Filters based on the result associated with the selected test kit. + Requires prior join to tk_items_t as alias 'tk' (via WHICH_TEST_KIT). + Uses comparator and uppercase value. + """ + try: + comparator = self.criteria_comparator + value = self.criteria_value.strip().upper() + self.sql_where.append(f"AND tk.test_results {comparator} '{value}'") + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_kit_has_analyser_result_code(self) -> None: + """ + Filters kits based on whether they have an analyser error code. + Requires prior join to tk_items_t as alias 'tk' (via WHICH_TEST_KIT). + + Accepts values: + - "yes" → analyser_error_code IS NOT NULL + - "no" → analyser_error_code IS NULL + """ + try: + value = self.criteria_value.strip().lower() + + if value == "yes": + self.sql_where.append("AND tk.analyser_error_code IS NOT NULL") + elif value == "no": + self.sql_where.append("AND tk.analyser_error_code IS NULL") + else: + raise ValueError( + f"Invalid value for analyser result code presence: {value}" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_join_to_appointments(self) -> None: + """ + Adds join to appointment_t table based on appointment selection strategy. + Requires prior join to latest episode (ep). Aliases the appointment table as 'ap'. + + Accepts values: + - "any_appointment_in_latest_episode" + - "latest_appointment_in_latest_episode" + - "earlier_appointment_in_latest_episode" + - "later_appointment_in_latest_episode" + """ + try: + value = self.criteria_value.strip().lower() + ap_alias = "ap" + apr_alias = "ap_prev" # Simulated prior alias for test support + + self._add_join_to_latest_episode() + self.sql_from.append( + f"INNER JOIN appointment_t {ap_alias} ON {ap_alias}.subject_epis_id = ep.subject_epis_id" + ) + + if value == "any_appointment_in_latest_episode": + return + elif value == "latest_appointment_in_latest_episode": + self.sql_from.append( + f"AND {ap_alias}.appointment_id = (" + f" SELECT MAX(apx.appointment_id)" + f" FROM appointment_t apx" + f" WHERE apx.subject_epis_id = ep.subject_epis_id" + f" AND apx.void = 'N')" + ) + elif value == "earlier_appointment_in_latest_episode": + self.sql_from.append( + f"AND {ap_alias}.appointment_id < {apr_alias}.appointment_id" + ) + elif value == "later_appointment_in_latest_episode": + self.sql_from.append( + f"AND {ap_alias}.appointment_id > {apr_alias}.appointment_id" + ) + else: + raise ValueError(f"Invalid appointment selection value: {value}") + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_appointment_type(self) -> None: + """ + Filters appointments by slot type (e.g. clinic, phone). + Requires prior join to appointment_t as alias 'ap' (via WHICH_APPOINTMENT). + + Uses comparator and resolves slot type label to ID via AppointmentSlotType. + """ + try: + comparator = self.criteria_comparator + value = self.criteria_value.strip() + slot_type_id = AppointmentSlotType.get_id(value) + + self.sql_where.append( + f"AND ap.appointment_slot_type_id {comparator} {slot_type_id}" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_appointment_status(self) -> None: + """ + Filters appointments by status (e.g. booked, attended). + Requires prior join to appointment_t as alias 'ap'. + + Uses comparator and resolves status label to ID via AppointmentStatusType. + """ + try: + comparator = self.criteria_comparator + value = self.criteria_value.strip() + status_id = AppointmentStatusType.get_id(value) + + self.sql_where.append( + f"AND ap.appointment_status_id {comparator} {status_id}" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_join_to_diagnostic_tests(self) -> None: + try: + which = WhichDiagnosticTest.from_description(self.criteria_value) + idx = getattr(self, "criteria_index", 0) + xt = f"xt{idx}" + xtp = f"xt{idx - 1}" + + self.sql_from.append( + f"INNER JOIN external_tests_t {xt} ON {xt}.screening_subject_id = ss.screening_subject_id" + ) + + if which == WhichDiagnosticTest.ANY_TEST_IN_ANY_EPISODE: + return + + self._add_join_to_latest_episode() + + handlers = { + WhichDiagnosticTest.ANY_TEST_IN_LATEST_EPISODE: self._handle_any_test_in_latest_episode, + WhichDiagnosticTest.ONLY_TEST_IN_LATEST_EPISODE: self._handle_only_test_in_latest_episode, + WhichDiagnosticTest.ONLY_NOT_VOID_TEST_IN_LATEST_EPISODE: self._handle_only_test_in_latest_episode, + WhichDiagnosticTest.LATEST_TEST_IN_LATEST_EPISODE: self._handle_latest_test_in_latest_episode, + WhichDiagnosticTest.LATEST_NOT_VOID_TEST_IN_LATEST_EPISODE: self._handle_latest_test_in_latest_episode, + WhichDiagnosticTest.EARLIEST_NOT_VOID_TEST_IN_LATEST_EPISODE: self._handle_earliest_test_in_latest_episode, + WhichDiagnosticTest.EARLIER_TEST_IN_LATEST_EPISODE: self._handle_earlier_or_later_test, + WhichDiagnosticTest.LATER_TEST_IN_LATEST_EPISODE: self._handle_earlier_or_later_test, + } + + if which in handlers: + handlers[which](which, xt, xtp) + else: + raise ValueError(f"Unsupported diagnostic test type: {which}") + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _handle_any_test_in_latest_episode(self, which, xt, _): + """Helper method for diagnostic test filtering""" + + self.sql_from.append(f"AND {xt}.subject_epis_id = ep.subject_epis_id") + + def _handle_only_test_in_latest_episode(self, which, xt, _): + """Helper method for diagnostic test filtering""" + self.sql_from.append(f"AND {xt}.subject_epis_id = ep.subject_epis_id") + if which == WhichDiagnosticTest.ONLY_NOT_VOID_TEST_IN_LATEST_EPISODE: + self.sql_from.append(f"AND {xt}.void = 'N'") + self.sql_from.append( + f"""AND NOT EXISTS ( + SELECT 'xto' FROM external_tests_t xto + WHERE xto.screening_subject_id = ss.screening_subject_id + {'AND xto.void = \'N\'' if which == WhichDiagnosticTest.ONLY_NOT_VOID_TEST_IN_LATEST_EPISODE else ''} + AND xto.subject_epis_id = ep.subject_epis_id + AND xto.ext_test_id != {xt}.ext_test_id )""" + ) + + def _handle_latest_test_in_latest_episode(self, which, xt, _): + """Helper method for diagnostic test filtering""" + self.sql_from.append( + f"""AND {xt}.ext_test_id = ( + SELECT MAX(xtx.ext_test_id) FROM external_tests_t xtx + WHERE xtx.screening_subject_id = ss.screening_subject_id + {'AND xtx.void = \'N\'' if which == WhichDiagnosticTest.LATEST_NOT_VOID_TEST_IN_LATEST_EPISODE else ''} + AND xtx.subject_epis_id = ep.subject_epis_id )""" + ) + + def _handle_earliest_test_in_latest_episode(self, which, xt, _): + """Helper method for diagnostic test filtering""" + self.sql_from.append( + f"""AND {xt}.ext_test_id = ( + SELECT MIN(xtn.ext_test_id) FROM external_tests_t xtn + WHERE xtn.screening_subject_id = ss.screening_subject_id + AND xtn.void = 'N' + AND xtn.subject_epis_id = ep.subject_epis_id )""" + ) + + def _handle_earlier_or_later_test(self, which, xt, xtp): + """Helper method for diagnostic test filtering""" + if getattr(self, "criteria_index", 0) == 0: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + comparator = ( + "<" if which == WhichDiagnosticTest.EARLIER_TEST_IN_LATEST_EPISODE else ">" + ) + self.sql_from.append(f"AND {xt}.ext_test_id {comparator} {xtp}.ext_test_id") + + def _add_criteria_diagnostic_test_type(self, proposed_or_confirmed: str) -> None: + """ + Filters diagnostic tests by type—proposed or confirmed. + Requires prior join to external_tests_t (xt aliasing assumed). + """ + try: + idx = getattr(self, "criteria_index", 0) + xt = f"xt{idx}" + + if proposed_or_confirmed == "proposed": + column = f"{xt}.proposed_type_id" + elif proposed_or_confirmed == "confirmed": + column = f"{xt}.confirmed_type_id" + else: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) + + self.sql_where.append(f"AND {column} ") + + value = self.criteria_value.strip().lower() + if value == "null": + self.sql_where.append(self._SQL_IS_NULL) + elif value == "not null": + self.sql_where.append(self._SQL_IS_NOT_NULL) + else: + comparator = self.criteria_comparator + type_id = DiagnosticTestType.get_valid_value_id(self.criteria_value) + self.sql_where.append(f"{comparator} {type_id}") + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_diagnostic_test_is_void(self) -> None: + """ + Adds WHERE clause to check whether diagnostic test is voided ('Y' or 'N'). + Requires prior join to external_tests_t using alias xtN. + """ + try: + idx = getattr(self, "criteria_index", 0) + xt = f"xt{idx}" + value = DiagnosticTestIsVoid.from_description(self.criteria_value) + + if value == DiagnosticTestIsVoid.YES: + self.sql_where.append(f"AND {xt}.void = 'Y'") + elif value == DiagnosticTestIsVoid.NO: + self.sql_where.append(f"AND {xt}.void = 'N'") + else: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_diagnostic_test_has_result(self) -> None: + """ + Adds WHERE clause to check whether a diagnostic test has a result (IS NULL / NOT NULL / = result_id). + """ + try: + idx = getattr(self, "criteria_index", 0) + xt = f"xt{idx}" + value = self.criteria_value.strip().lower() + result = DiagnosticTestHasResult.from_description(value) + + self.sql_where.append(f"AND {xt}.result_id ") + + if result == DiagnosticTestHasResult.YES: + self.sql_where.append("IS NOT NULL") + elif result == DiagnosticTestHasResult.NO: + self.sql_where.append("IS NULL") + else: + result_id = DiagnosticTestHasResult.get_id(value) + self.sql_where.append(f"= {result_id}") + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_diagnostic_test_has_outcome_of_result(self) -> None: + """ + Adds WHERE clause filtering on whether the diagnostic test has an outcome-of-result. + """ + try: + idx = getattr(self, "criteria_index", 0) + xt = f"xt{idx}" + value = self.criteria_value.strip().lower() + outcome = DiagnosticTestHasOutcomeOfResult.from_description(value) + + self.sql_where.append(f"AND {xt}.outcome_of_result_id ") + + if outcome == DiagnosticTestHasOutcomeOfResult.YES: + self.sql_where.append("IS NOT NULL") + elif outcome == DiagnosticTestHasOutcomeOfResult.NO: + self.sql_where.append("IS NULL") + else: + outcome_id = DiagnosticTestHasOutcomeOfResult.get_id(value) + self.sql_where.append(f"= {outcome_id}") + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_diagnostic_test_intended_extent(self) -> None: + """ + Adds WHERE clause filtering diagnostic tests by intended_extent_id. + Supports null checks and value comparisons. + """ + try: + idx = getattr(self, "criteria_index", 0) + xt = f"xt{idx}" + extent = IntendedExtentType.from_description(self.criteria_value) + + self.sql_where.append(f"AND {xt}.intended_extent_id ") + + if extent in (IntendedExtentType.NULL, IntendedExtentType.NOT_NULL): + self.sql_where.append( + f"IS {IntendedExtentType.get_description(extent)}" + ) + else: + self.sql_where.append( + f"{self.criteria_comparator} {IntendedExtentType.get_id(self.criteria_value)}" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_latest_episode_has_dataset(self) -> None: + """ + Filters based on presence or completion status of a dataset in the latest episode. + """ + try: + self._add_join_to_latest_episode() + + dataset_info = self._dataset_source_for_criteria_key() + dataset_table = dataset_info["table"] + alias = dataset_info["alias"] + + clause = "AND EXISTS ( " + value = self.criteria_value.strip().lower() + status = LatestEpisodeHasDataset.from_description(value) + filter_clause = "" + + if status == LatestEpisodeHasDataset.NO: + clause = "AND NOT EXISTS ( " + elif status == LatestEpisodeHasDataset.YES_INCOMPLETE: + filter_clause = f"AND {alias}.dataset_completed_date IS NULL" + elif status == LatestEpisodeHasDataset.YES_COMPLETE: + filter_clause = f"AND {alias}.dataset_completed_date IS NOT NULL" + elif status == LatestEpisodeHasDataset.PAST: + filter_clause = ( + f"AND TRUNC({alias}.dataset_completed_date) < TRUNC(SYSDATE)" + ) + else: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) + + self.sql_where.append( + "".join( + [ + clause, + f"SELECT 1 FROM {dataset_table} {alias} ", + f"WHERE {alias}.episode_id = ep.subject_epis_id ", + f"AND {alias}.deleted_flag = 'N' ", + filter_clause, + ")", + ] + ) + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _dataset_source_for_criteria_key(self) -> dict: + """ + Internal helper method. + Maps LATEST_EPISODE_HAS_* criteria keys to their corresponding dataset tables and aliases. + Used by _add_criteria_latest_episode_has_dataset(). + """ + key = self.criteria_key + if key == SubjectSelectionCriteriaKey.LATEST_EPISODE_HAS_CANCER_AUDIT_DATASET: + return {"table": "ds_cancer_audit_t", "alias": "cads"} + if ( + key + == SubjectSelectionCriteriaKey.LATEST_EPISODE_HAS_COLONOSCOPY_ASSESSMENT_DATASET + ): + return {"table": "ds_patient_assessment_t", "alias": "dspa"} + if key == SubjectSelectionCriteriaKey.LATEST_EPISODE_HAS_MDT_DATASET: + return {"table": "ds_mdt_t", "alias": "mdt"} + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_latest_episode_latest_investigation_dataset(self) -> None: + """ + Filters subjects based on their latest investigation dataset in their latest episode. + Supports colonoscopy and radiology variations. + """ + try: + self._add_join_to_latest_episode() + value = LatestEpisodeLatestInvestigationDataset.from_description( + self.criteria_value + ) + + if value == "none": + self.sql_where.append( + "AND NOT EXISTS (SELECT 'dsc1' FROM v_ds_colonoscopy dsc1 " + "WHERE dsc1.episode_id = ep.subject_epis_id " + "AND dsc1.confirmed_type_id = 16002)" + ) + elif value == "colonoscopy_new": + self.sql_where.append( + "AND EXISTS (SELECT 'dsc2' FROM v_ds_colonoscopy dsc2 " + "WHERE dsc2.episode_id = ep.subject_epis_id " + "AND dsc2.confirmed_type_id = 16002 " + "AND dsc2.deleted_flag = 'N' " + "AND dsc2.dataset_new_flag = 'Y')" + ) + elif value == "limited_colonoscopy_new": + self.sql_where.append( + "AND EXISTS (SELECT 'dsc3' FROM v_ds_colonoscopy dsc3 " + "WHERE dsc3.episode_id = ep.subject_epis_id " + "AND dsc3.confirmed_type_id = 17996 " + "AND dsc3.deleted_flag = 'N' " + "AND dsc3.dataset_new_flag = 'Y')" + ) + elif value == "flexible_sigmoidoscopy_new": + self.sql_where.append( + "AND EXISTS (SELECT 'dsc4' FROM v_ds_colonoscopy dsc4 " + "WHERE dsc4.episode_id = ep.subject_epis_id " + "AND dsc4.confirmed_type_id = 16004 " + "AND dsc4.deleted_flag = 'N' " + "AND dsc4.dataset_new_flag = 'Y')" + ) + elif value == "ct_colonography_new": + self.sql_where.append( + "AND EXISTS (SELECT 'dsr1' FROM v_ds_radiology dsr1 " + "WHERE dsr1.episode_id = ep.subject_epis_id " + "AND dsr1.confirmed_type_id = 16087 " + "AND dsr1.deleted_flag = 'N' " + "AND dsr1.dataset_new_flag = 'Y')" + ) + elif value == "endoscopy_incomplete": + self.sql_where.append( + "AND EXISTS (SELECT 'dsei' FROM v_ds_colonoscopy dsei " + "WHERE dsei.episode_id = ep.subject_epis_id " + "AND dsei.deleted_flag = 'N' " + "AND dsei.dataset_completed_flag = 'N' " + "AND dsei.dataset_new_flag = 'N' " + "AND dsei.confirmed_test_date >= TO_DATE('01/01/2020','dd/mm/yyyy'))" + ) + elif value == "radiology_incomplete": + self.sql_where.append( + "AND EXISTS (SELECT 'dsri' FROM v_ds_radiology dsri " + "WHERE dsri.episode_id = ep.subject_epis_id " + "AND dsri.deleted_flag = 'N' " + "AND dsri.dataset_completed_flag = 'N' " + "AND dsri.dataset_new_flag = 'N' " + "AND dsri.confirmed_test_date >= TO_DATE('01/01/2020','dd/mm/yyyy'))" + ) + else: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_latest_episode_intended_extent(self) -> None: + """ + Filters subjects based on presence of a colonoscopy dataset with a specific intended_extent_id + in their latest episode. + """ + try: + self._add_join_to_latest_episode() + extent_id = IntendedExtentType.get_id(self.criteria_value) + + self.sql_where.append( + "AND EXISTS (SELECT 'dsc' FROM v_ds_colonoscopy dsc " + "WHERE dsc.episode_id = ep.subject_epis_id " + f"AND dsc.intended_extent_id = {extent_id})" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_surveillance_review_status(self) -> None: + """ + Filters subjects based on the review_status_id in their surveillance review dataset. + """ + try: + self._add_join_to_surveillance_review() + status_id = SurveillanceReviewStatusType.get_id(self.criteria_value) + + self.sql_where.append( + f"AND sr.review_status_id {self.criteria_comparator} {status_id}" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_join_to_surveillance_review(self) -> None: + """ + Internal helper. Adds the necessary join to the surveillance review dataset for filtering. + """ + self.sql_from.append("-- JOIN to surveillance review placeholder") + + def _add_criteria_does_subject_have_surveillance_review_case(self) -> None: + """ + Filters subjects based on presence or absence of a surveillance review case. + """ + try: + value = DoesSubjectHaveSurveillanceReviewCase.from_description( + self.criteria_value + ) + + clause = ( + self._SQL_AND_EXISTS if value == "yes" else self._SQL_AND_NOT_EXISTS + ) + + self.sql_where.append( + f"{clause} (SELECT 'sr' FROM surveillance_review sr " + "WHERE sr.subject_id = ss.screening_subject_id)" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_surveillance_review_type(self) -> None: + """ + Filters subjects based on review_case_type_id in the surveillance review dataset. + """ + try: + self._add_join_to_surveillance_review() + type_id = SurveillanceReviewCaseType.get_id(self.criteria_value) + + self.sql_where.append( + f"AND sr.review_case_type_id {self.criteria_comparator} {type_id}" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_has_date_of_death_removal(self) -> None: + """ + Filters subjects based on presence or absence of a date-of-death removal record. + """ + try: + value = HasDateOfDeathRemoval.from_description(self.criteria_value) + clause = "EXISTS" if value == "yes" else self._SQL_NOT_EXISTS + + self.sql_where.append( + f"AND {clause} (SELECT 'dodr' FROM report_additional_data_t dodr " + "WHERE dodr.rad_type_id = 15901 " + "AND dodr.entity_id = c.contact_id)" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_invited_since_age_extension(self) -> None: + """ + Filters subjects based on whether they were invited since age extension began. + """ + try: + self._add_join_to_latest_episode() + value = InvitedSinceAgeExtension.from_description(self.criteria_value) + clause = "EXISTS" if value == "yes" else self._SQL_NOT_EXISTS + + self.sql_where.append( + f"AND {clause} (SELECT 'sagex' FROM screening_subject_attribute_t sagex " + "INNER JOIN valid_values vvagex ON vvagex.valid_value_id = sagex.attribute_id " + "AND vvagex.domain = 'FOBT_AGEX_LOWER_AGE' " + "WHERE sagex.screening_subject_id = ep.screening_subject_id " + "AND sagex.start_date < ep.episode_start_date)" + ) + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_note_count(self) -> None: + """ + Filters subjects based on the count of associated supporting notes. + """ + try: + # Assumes criteriaValue contains both comparator and numeric literal, e.g., '>= 2' + comparator_clause = self.criteria_value.strip() + + self.sql_where.append( + "AND (SELECT COUNT(*) FROM SUPPORTING_NOTES_T snt " + "WHERE snt.screening_subject_id = ss.screening_subject_id) " + f"{comparator_clause}" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_latest_episode_accumulated_episode_result(self) -> None: + """ + Filters subjects based on the result of their latest episode. + """ + try: + self._add_join_to_latest_episode() + value = EpisodeResultType.from_description(self.criteria_value) + + if value == EpisodeResultType.NULL: + self.sql_where.append("AND ep.episode_result_id IS NULL") + elif value == EpisodeResultType.NOT_NULL: + self.sql_where.append("AND ep.episode_result_id IS NOT NULL") + elif value == EpisodeResultType.ANY_SURVEILLANCE_NON_PARTICIPATION: + self.sql_where.append( + "AND ep.episode_result_id IN (" + "SELECT snp.valid_value_id FROM valid_values snp " + "WHERE snp.domain = 'OTHER_EPISODE_RESULT' " + "AND LOWER(snp.description) LIKE '%surveillance non-participation')" + ) + else: + self.sql_where.append(f"AND ep.episode_result_id = {value}") + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_symptomatic_procedure_result(self) -> None: + """ + Filters based on symptomatic surgery result value or presence. + """ + try: + column = "xt.surgery_result_id" + value = self.criteria_value.strip().lower() + + if value == "null": + self.sql_where.append(f"AND {column} IS NULL") + else: + result_id = SymptomaticProcedureResultType.get_id(self.criteria_value) + self.sql_where.append( + f"AND {column} {self.criteria_comparator} {result_id}" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_screening_referral_type(self) -> None: + """ + Filters based on screening referral type ID or null presence. + """ + try: + column = "xt.screening_referral_type_id" + value = self.criteria_value.strip().lower() + + if value == "null": + self.sql_where.append(f"AND {column} IS NULL") + else: + type_id = ScreeningReferralType.get_id(self.criteria_value) + self.sql_where.append( + f"AND {column} {self.criteria_comparator} {type_id}" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_lynch_due_date_reason( + self, subject: Optional[Subject] = None + ) -> None: + """ + Filters based on Lynch due date change reason. Supports symbolic types and subject comparison. + """ + try: + column = "ss.lynch_sdd_reason_for_change_id" + reason = LynchDueDateReasonType.from_description(self.criteria_value) + + if reason == LynchDueDateReasonType.NULL: + self.sql_where.append(f"AND {column} IS NULL") + + elif reason == LynchDueDateReasonType.NOT_NULL: + self.sql_where.append(f"AND {column} IS NOT NULL") + + elif reason == LynchDueDateReasonType.UNCHANGED: + if subject is None: + raise SelectionBuilderException( + self.criteria_key_name, + "No subject provided for 'unchanged' logic", + ) + elif getattr(subject, "lynch_due_date_change_reason_id", None) is None: + self.sql_where.append(f"AND {column} IS NULL") + else: + self.sql_where.append( + f"AND {column} = {subject.lynch_due_date_change_reason_id}" + ) + + else: + self.sql_where.append( + f"AND {column} {self.criteria_comparator} {reason}" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_lynch_incident_episode(self) -> None: + """ + Filters based on linkage to a Lynch incident episode. + """ + try: + self._add_join_to_latest_episode() + column = "ss.lynch_incident_subject_epis_id" + value = LynchIncidentEpisodeType.from_description(self.criteria_value) + + if value == LynchIncidentEpisodeType.NULL: + self.sql_where.append(f"AND {column} IS NULL") + + elif value == LynchIncidentEpisodeType.NOT_NULL: + self.sql_where.append(f"AND {column} IS NOT NULL") + + elif value == LynchIncidentEpisodeType.LATEST_EPISODE: + self.sql_where.append(f"AND {column} = ep.subject_epis_id") + + elif value == LynchIncidentEpisodeType.EARLIER_EPISODE: + self.sql_where.append(f"AND {column} < ep.subject_epis_id") + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_fobt_prevalent_incident_status(self) -> None: + """ + Filters subjects by whether their FOBT episode is prevalent or incident. + """ + try: + value = PrevalentIncidentStatusType.from_description(self.criteria_value) + column = "ss.fobt_incident_subject_epis_id" + + if value == PrevalentIncidentStatusType.PREVALENT: + self.sql_where.append(f"AND {column} IS NULL") + elif value == PrevalentIncidentStatusType.INCIDENT: + self.sql_where.append(f"AND {column} IS NOT NULL") + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_notify_queued_message_status(self) -> None: + """ + Filters subjects based on Notify queued message status, e.g. 'S1 (S1w) - new'. + """ + try: + parts = parse_notify_criteria(self.criteria_value) + status = parts["status"] + + if status == "none": + clause = self._SQL_NOT_EXISTS + else: + clause = "EXISTS" + + self.sql_where.append(f"AND {clause} (") + self.sql_where.append( + "SELECT 1 FROM notify_message_queue nmq " + "INNER JOIN notify_message_definition nmd ON nmd.message_definition_id = nmq.message_definition_id " + "WHERE nmq.nhs_number = c.nhs_number " + ) + + # Simulate getNotifyMessageEventStatusIdFromCriteria() + event_status_id = NotifyEventStatus.get_id(parts["type"]) + self.sql_where.append(f"AND nmd.event_status_id = {event_status_id} ") + + if status != "none": + self.sql_where.append(f"AND nmq.message_status = '{status}' ") + + if "code" in parts and parts["code"]: + self.sql_where.append(f"AND nmd.message_code = '{parts['code']}' ") + + self.sql_where.append(")") + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_notify_archived_message_status(self) -> None: + """ + Filters subjects based on archived Notify message criteria, e.g. 'S1 (S1w) - sending'. + """ + try: + parts = parse_notify_criteria(self.criteria_value) + status = parts["status"] + + clause = self._SQL_NOT_EXISTS if status == "none" else "EXISTS" + + self.sql_where.append(f"AND {clause} (") + self.sql_where.append( + "SELECT 1 FROM notify_message_record nmr " + "INNER JOIN notify_message_batch nmb ON nmb.batch_id = nmr.batch_id " + "INNER JOIN notify_message_definition nmd ON nmd.message_definition_id = nmb.message_definition_id " + "WHERE nmr.subject_id = ss.screening_subject_id " + ) + + event_status_id = NotifyEventStatus.get_id(parts["type"]) + self.sql_where.append(f"AND nmd.event_status_id = {event_status_id} ") + + if "code" in parts and parts["code"]: + self.sql_where.append(f"AND nmd.message_code = '{parts['code']}' ") + + if status != "none": + self.sql_where.append(f"AND nmr.message_status = '{status}' ") + + self.sql_where.append(")") + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_has_previously_had_cancer(self) -> None: + """ + Filters based on whether the subject previously had cancer. + """ + try: + answer = YesNoType.from_description(self.criteria_value) + condition = "'Y'" if answer == YesNoType.YES else "'N'" + + self.sql_where.append( + f"AND pkg_letters.f_subj_prev_diagnosed_cancer(pi_subject_id => ss.screening_subject_id) = {condition}" + ) + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_has_temporary_address(self) -> None: + """ + Filters subjects based on whether they have a temporary address on record. + """ + try: + answer = YesNoType.from_description(self.criteria_value) + + if answer == YesNoType.YES: + self.sql_from.append( + " INNER JOIN sd_address_t adds ON adds.contact_id = c.contact_id " + " AND adds.ADDRESS_TYPE = 13043 " + " AND adds.EFFECTIVE_FROM IS NOT NULL " + ) + elif answer == YesNoType.NO: + self.sql_from.append( + " LEFT JOIN sd_address_t adds ON adds.contact_id = c.contact_id " + ) + self.sql_where.append( + " AND NOT EXISTS (" + " SELECT 1 " + " FROM sd_address_t x " + " WHERE x.contact_id = c.contact_id " + " AND x.address_type = 13043" + " AND x.effective_from is not null) " + ) + else: + raise ValueError() + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + # ------------------------------------------------------------------------ + # 🧬 CADS Clinical Dataset Filters + # ------------------------------------------------------------------------ + + def _add_criteria_cads_asa_grade(self) -> None: + self._add_join_to_latest_episode() + self._add_join_to_cancer_audit_dataset() + self.sql_where.append( + "AND cads.asa_grade_id = ASAGradeType.by_description_case_insensitive(self.criteria_value).id" + ) + + def _add_criteria_cads_staging_scans(self) -> None: + self._add_join_to_latest_episode() + self._add_join_to_cancer_audit_dataset() + self._add_join_to_cancer_audit_dataset_staging_scan() + self.sql_where.append( + "AND cads.staging_scans_done_id = YesNoType.by_description_case_insensitive(self.criteria_value).id" + ) + + def _add_criteria_cads_type_of_scan(self) -> None: + self._add_join_to_latest_episode() + self._add_join_to_cancer_audit_dataset() + self._add_join_to_cancer_audit_dataset_staging_scan() + self.sql_where.append( + "AND dcss.type_of_scan_id = ScanType.by_description_case_insensitive(self.criteria_value).id" + ) + + def _add_criteria_cads_metastases_present(self) -> None: + self._add_join_to_latest_episode() + self._add_join_to_cancer_audit_dataset() + self.sql_where.append( + "AND cads.metastases_found_id = MetastasesPresentType.by_description_case_insensitive(self.criteria_value).id" + ) + + def _add_criteria_cads_metastases_location(self) -> None: + self._add_join_to_latest_episode() + self._add_join_to_cancer_audit_dataset() + self._add_join_to_cancer_audit_dataset_metastasis() + self.sql_where.append( + "AND dcm.location_of_metastasis_id = MetastasesLocationType.by_description_case_insensitive(self.criteria_value).id" + ) + + def _add_criteria_cads_metastases_other_location(self, other_location: str) -> None: + self._add_join_to_latest_episode() + self._add_join_to_cancer_audit_dataset() + self._add_join_to_cancer_audit_dataset_metastasis() + self.sql_where.append( + f"AND dcm.other_location_of_metastasis = '{other_location}'" + ) + + def _add_criteria_cads_final_pre_treatment_t_category(self) -> None: + self._add_join_to_latest_episode() + self._add_join_to_cancer_audit_dataset() + self.sql_where.append( + "AND cads.final_pre_treat_t_category_id = FinalPretreatmentTCategoryType.by_description_case_insensitive(self.criteria_value).id" + ) + + def _add_criteria_cads_final_pre_treatment_n_category(self) -> None: + self._add_join_to_latest_episode() + self._add_join_to_cancer_audit_dataset() + self.sql_where.append( + "AND cads.final_pre_treat_n_category_id = FinalPretreatmentNCategoryType.by_description_case_insensitive(self.criteria_value).id" + ) + + def _add_criteria_cads_final_pre_treatment_m_category(self) -> None: + self._add_join_to_latest_episode() + self._add_join_to_cancer_audit_dataset() + self.sql_where.append( + "AND cads.final_pre_treat_m_category_id = FinalPretreatmentMCategoryType.by_description_case_insensitive(self.criteria_value).id" + ) + + def _add_criteria_cads_treatment_received(self) -> None: + self._add_join_to_latest_episode() + self._add_join_to_cancer_audit_dataset() + self.sql_where.append( + "AND cads.treatment_received_id = YesNoType.by_description_case_insensitive(self.criteria_value).id" + ) + + def _add_criteria_cads_reason_no_treatment_received(self) -> None: + self._add_join_to_latest_episode() + self._add_join_to_cancer_audit_dataset() + self.sql_where.append( + "AND cads.reason_no_treatment_id = ReasonNoTreatmentReceivedType.by_description_case_insensitive(self.criteria_value).id" + ) + + def _add_criteria_cads_tumour_location(self) -> None: + self._add_join_to_latest_episode() + self._add_join_to_cancer_audit_dataset() + self._add_join_to_cancer_audit_dataset_tumour() + self.sql_where.append( + "AND dctu.location_id = LocationType.by_description_case_insensitive(self.criteria_value).id" + ) + + def _add_criteria_cads_tumour_height_of_tumour_above_anal_verge(self) -> None: + self._add_join_to_latest_episode() + self._add_join_to_cancer_audit_dataset() + self._add_join_to_cancer_audit_dataset_tumour() + self.sql_where.append( + "AND dctu.height_above_anal_verge = {self.criteria_value}" + ) + + def _add_criteria_cads_tumour_previously_excised_tumour(self) -> None: + self._add_join_to_latest_episode() + self._add_join_to_cancer_audit_dataset() + self._add_join_to_cancer_audit_dataset_tumour() + self.sql_where.append( + "AND dctu.recurrence_id = PreviouslyExcisedTumourType.by_description_case_insensitive(self.criteria_value).id" + ) + + def _add_criteria_cads_treatment_type(self) -> None: + self._add_join_to_latest_episode() + self._add_join_to_cancer_audit_dataset() + self._add_join_to_cancer_audit_dataset_treatment() + self.sql_where.append( + "AND dctr.treatment_category_id = TreatmentType.by_description_case_insensitive(self.criteria_value).id" + ) + + def _add_criteria_cads_treatment_given(self) -> None: + self._add_join_to_latest_episode() + self._add_join_to_cancer_audit_dataset() + self._add_join_to_cancer_audit_dataset_treatment() + self.sql_where.append( + "AND dctr.treatment_procedure_id = TreatmentGiven.by_description_case_insensitive(self.criteria_value).id" + ) + + def _add_criteria_cads_cancer_treatment_intent(self) -> None: + self._add_join_to_latest_episode() + self._add_join_to_cancer_audit_dataset() + self._add_join_to_cancer_audit_dataset_treatment() + self.sql_where.append( + "AND dctr.treatment_intent_id = CancerTreatmentIntent.by_description_case_insensitive(self.criteria_value).id" + ) + + def _add_join_to_cancer_audit_dataset_staging_scan(self) -> None: + self.sql_from.append( + "INNER JOIN data_cancer_audit_dataset_staging_scan dcss ON dcss.cancer_audit_dataset_id = cads.cancer_audit_dataset_id" + ) + + def _add_join_to_cancer_audit_dataset_metastasis(self) -> None: + self.sql_from.append( + "INNER JOIN data_cancer_audit_dataset_metastasis dcm ON dcm.cancer_audit_dataset_id = cads.cancer_audit_dataset_id" + ) + + def _add_join_to_cancer_audit_dataset_tumour(self) -> None: + self.sql_from.append( + "INNER JOIN data_cancer_audit_dataset_tumour dctu ON dctu.cancer_audit_dataset_id = cads.cancer_audit_dataset_id" + ) + + def _add_criteria_subject_hub_code(self, user: "User") -> None: + hub_code = None + try: + hub_enum = SubjectHubCode.by_description(self.criteria_value.lower()) + if hub_enum in [SubjectHubCode.USER_HUB, SubjectHubCode.USER_ORGANISATION]: + if ( + user.organisation is None + or user.organisation.organisation_id is None + ): + raise ValueError("User organisation or organisation_id is None") + hub_code = user.organisation.organisation_id + else: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) + except Exception: + # If not in the enum it must be an actual hub code + hub_code = self.criteria_value + + self.sql_where.append(" AND c.hub_id ") + self.sql_where.append(self.criteria_comparator) + self.sql_where.append(" (") + self.sql_where.append(" SELECT hub.org_id ") + self.sql_where.append(" FROM org hub ") + self.sql_where.append(" WHERE hub.org_code = ") + self.sql_where.append(self.single_quoted(hub_code.upper())) + self.sql_where.append(") ") + + def _add_criteria_subject_screening_centre_code(self, user: "User"): + sc_code = None + + try: + option = SubjectScreeningCentreCode.by_description( + self.criteria_value.lower() + ) + match option: + case SubjectScreeningCentreCode.NONE | SubjectScreeningCentreCode.NULL: + self.sql_where.append(" AND c.responsible_sc_id IS NULL ") + case SubjectScreeningCentreCode.NOT_NULL: + self.sql_where.append(" AND c.responsible_sc_id IS NOT NULL ") + case ( + SubjectScreeningCentreCode.USER_SCREENING_CENTRE + | SubjectScreeningCentreCode.USER_SC + | SubjectScreeningCentreCode.USER_ORGANISATION + ): + if ( + user.organisation is None + or user.organisation.organisation_id is None + ): + raise ValueError("User organisation or organisation_id is None") + sc_code = user.organisation.organisation_id + case _: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) + except SelectionBuilderException as ssbe: + raise ssbe + except Exception: + # If not in enum, treat as an actual SC code + sc_code = self.criteria_value + + if sc_code is not None: + self.sql_where.append( + f" AND c.responsible_sc_id {self.criteria_comparator} (" + " SELECT sc.org_id " + " FROM org sc " + f" WHERE sc.org_code = {self.single_quoted(sc_code.upper())}" + ") " + ) + + def _add_criteria_has_gp_practice(self): + try: + option = HasGPPractice.by_description(self.criteria_value.lower()) + + match option: + case HasGPPractice.YES_ACTIVE: + self.sql_from.append( + " INNER JOIN gp_practice_current_links gpl " + " ON gpl.gp_practice_id = c.gp_practice_id " + ) + case HasGPPractice.YES_INACTIVE: + self.sql_where.append( + " AND c.gp_practice_id IS NOT NULL " + " AND c.gp_practice_id NOT IN ( " + " SELECT gpl.gp_practice_id " + " FROM gp_practice_current_links gpl ) " + ) + case HasGPPractice.NO: + self.sql_where.append(" AND c.gp_practice_id IS NULL ") + case _: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_has_gp_practice_linked_to_sc(self) -> None: + self.sql_where.append( + " AND c.gp_practice_id IN ( " + " SELECT o.org_id FROM gp_practice_current_links gpcl " + " INNER JOIN org o ON gpcl.gp_practice_id = o.org_id " + " WHERE gpcl.sc_id = ( " + f" SELECT org_id FROM org WHERE org_code = {self.single_quoted(self.criteria_value)})) " + ) + + def _add_criteria_screening_status(self, subject: "Subject"): + self.sql_where.append(" AND ss.screening_status_id ") + + if self.criteria_value.lower() == "unchanged": + self._force_not_modifier_is_invalid_for_criteria_value() + if subject is None: + raise self.invalid_use_of_unchanged_exception( + self.criteria_key_name, self._REASON_NO_EXISTING_SUBJECT + ) + self.sql_where.append(" = ") + self.sql_where.append(subject.get_screening_status_id()) + else: + try: + screening_status_type = ( + ScreeningStatusType.by_description_case_insensitive( + self.criteria_value + ) + ) + if screening_status_type is None: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) + self.sql_where.append(self.criteria_comparator) + self.sql_where.append(screening_status_type.valid_value_id) + except Exception: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) + + def _add_criteria_previous_screening_status(self): + screening_status_type = ScreeningStatusType.by_description_case_insensitive( + self.criteria_value + ) + if screening_status_type is None: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + self.sql_where.append(" AND ss.previous_screening_status_id ") + + match screening_status_type: + case ScreeningStatusType.NULL: + self.sql_where.append(self._SQL_IS_NULL) + case ScreeningStatusType.NOT_NULL: + self.sql_where.append(self._SQL_IS_NOT_NULL) + case _: + self.sql_where.append( + f"{self.criteria_comparator}{screening_status_type.valid_value_id}" + ) + + def _add_criteria_screening_status_reason(self, subject: "Subject"): + if self.criteria_value.lower() == "unchanged": + self._force_not_modifier_is_invalid_for_criteria_value() + if subject is None: + raise self.invalid_use_of_unchanged_exception( + self.criteria_key_name, self._REASON_NO_EXISTING_SUBJECT + ) + elif subject.get_screening_status_change_reason_id() is None: + self.sql_where.append(" AND ss.ss_reason_for_change_id IS NULL") + else: + self.sql_where.append( + f" AND ss.ss_reason_for_change_id = {subject.get_screening_status_change_reason_id()}" + ) + else: + try: + screening_status_change_reason_type = ( + SSReasonForChangeType.by_description_case_insensitive( + self.criteria_value + ) + ) + if screening_status_change_reason_type is None: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) + self.sql_where.append( + f" AND ss.ss_reason_for_change_id {self.criteria_comparator}{screening_status_change_reason_type.valid_value_id}" + ) + except Exception: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) + + def _add_criteria_date_field( + self, subject: "Subject", pathway: str, date_type: str + ) -> None: + date_column_name = self._get_date_field_column_name(pathway, date_type) + self._add_date_field_required_joins(date_column_name) + + criteria_words = self.criteria_value.split(" ") + + if self.criteria_value.isdigit(): + self._add_check_comparing_one_date_with_another( + date_column_name, + " = ", + self._add_years_to_oracle_date(self.c_dob, self.criteria_value), + False, + ) + elif ( + self.criteria_value.lower() != "last birthday" + and self.criteria_value.lower().endswith(" birthday") + and len(criteria_words) == 2 + ): + self._add_check_comparing_one_date_with_another( + date_column_name, + " = ", + self._add_years_to_oracle_date(self.c_dob, criteria_words[0][:-2]), + False, + ) + elif self._is_valid_date(self.criteria_value): + self._add_check_comparing_one_date_with_another( + date_column_name, + " = ", + self._oracle_to_date_method(self.criteria_value, "yyyy-mm-dd"), + False, + ) + elif self._is_valid_date(self.criteria_value, "%d/%m/%Y"): + self._add_check_comparing_one_date_with_another( + date_column_name, + " = ", + self._oracle_to_date_method(self.criteria_value, "dd/mm/yyyy"), + False, + ) + elif ( + self.criteria_value.endswith(" ago") + or self.criteria_value.endswith(" later") + ) and ( + len(criteria_words) == 3 + or ( + len(criteria_words) == 4 + and self.criteria_value.startswith(("> ", "< ", "<= ", ">=")) + ) + or ( + len(criteria_words) == 5 + and self.criteria_value.lower().startswith(("more than ", "less than ")) + ) + ): + self._add_check_date_is_a_period_ago_or_later( + date_column_name, self.criteria_value + ) + else: + self._add_criteria_date_field_special_cases( + self.criteria_value, subject, pathway, date_type, date_column_name + ) + + def _add_date_field_required_joins(self, column: str) -> None: + """Used by: _add_criteria_date_field + Determines which joins are needed based on the resolved date_column_name. + Keeps the main method focused on value handling, not join logic. + """ + if column.startswith("TRUNC(ep."): + self._add_join_to_latest_episode() + elif column.startswith("TRUNC(gcd."): + self._add_join_to_genetic_condition_diagnosis() + elif column.startswith("TRUNC(dctu."): + self._add_join_to_latest_episode() + self._add_join_to_cancer_audit_dataset() + self._add_join_to_cancer_audit_dataset_tumor() + elif column.startswith("TRUNC(dctr."): + self._add_join_to_latest_episode() + self._add_join_to_cancer_audit_dataset() + self._add_join_to_cancer_audit_dataset_treatment() + + def _add_criteria_screening_due_date_reason(self, subject: "Subject"): + due_date_reason = "ss.sdd_reason_for_change_id" + try: + screening_due_date_change_reason_type = ( + SDDReasonForChangeType.by_description_case_insensitive( + self.criteria_value + ) + ) + self.sql_where.append(" AND ") + + if screening_due_date_change_reason_type is None: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) + + match screening_due_date_change_reason_type: + case SDDReasonForChangeType.NULL: + self.sql_where.append(f"{due_date_reason}_SQL_IS_NULL") + case SDDReasonForChangeType.NOT_NULL: + self.sql_where.append(f"{due_date_reason}_SQL_IS_NOT_NULL") + case SDDReasonForChangeType.UNCHANGED: + self._force_not_modifier_is_invalid_for_criteria_value() + if subject is None: + raise self.invalid_use_of_unchanged_exception( + self.criteria_key_name, self._REASON_NO_EXISTING_SUBJECT + ) + elif subject.get_screening_due_date_change_reason_id() is None: + self.sql_where.append(f"{due_date_reason}_SQL_IS_NULL") + else: + self.sql_where.append( + f"{due_date_reason}{" = "}{subject.get_screening_due_date_change_reason_id()}" + ) + case _: + self.sql_where.append( + f"{due_date_reason}{self.criteria_comparator}{screening_due_date_change_reason_type.valid_value_id}" + ) + except SelectionBuilderException as ssbe: + raise ssbe + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_surveillance_due_date_reason(self, subject: "Subject"): + try: + surveillance_due_date_change_reason = ( + SSDDReasonForChangeType.by_description_case_insensitive( + self.criteria_value + ) + ) + self.sql_where.append(" AND ss.surveillance_sdd_rsn_change_id ") + + if surveillance_due_date_change_reason is None: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) + + match surveillance_due_date_change_reason: + case SSDDReasonForChangeType.NULL | SSDDReasonForChangeType.NOT_NULL: + self.sql_where.append( + f" IS {surveillance_due_date_change_reason.description}" + ) + case SSDDReasonForChangeType.UNCHANGED: + self._force_not_modifier_is_invalid_for_criteria_value() + if subject is None: + raise self.invalid_use_of_unchanged_exception( + self.criteria_key_name, self._REASON_NO_EXISTING_SUBJECT + ) + elif subject.get_surveillance_due_date_change_reason_id() is None: + self.sql_where.append(self._SQL_IS_NULL) + else: + self.sql_where.append( + f" = {subject.get_surveillance_due_date_change_reason_id()}" + ) + case _: + self.sql_where.append( + f"{self.criteria_comparator}{surveillance_due_date_change_reason.valid_value_id}" + ) + except SelectionBuilderException as ssbe: + raise ssbe + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_bowel_scope_due_date_reason(self): + try: + bowel_scope_due_date_change_reason_type = ( + BowelScopeDDReasonForChangeType.by_description(self.criteria_value) + ) + + if bowel_scope_due_date_change_reason_type is None: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) + + self.sql_where.append(" AND ss.fs_sdd_reason_for_change_id ") + self.sql_where.append( + f"{self.criteria_comparator}{bowel_scope_due_date_change_reason_type.valid_value_id}" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_manual_cease_requested(self) -> None: + try: + self.sql_where.append(" AND ss.cease_requested_status_id ") + + criterion = ManualCeaseRequested.by_description_case_insensitive( + self.criteria_value + ) + if criterion is None: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) + + match criterion: + case ManualCeaseRequested.NO: + self.sql_where.append(self._SQL_IS_NULL) + case ManualCeaseRequested.YES: + self.sql_where.append(self._SQL_IS_NOT_NULL) + case ManualCeaseRequested.DISCLAIMER_LETTER_REQUIRED: + self.sql_where.append("= 35") # C1 + case ManualCeaseRequested.DISCLAIMER_LETTER_SENT: + self.sql_where.append("= 36") # C2 + case _: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_ceased_confirmation_details(self): + self.sql_where.append(" AND LOWER(ss.ceased_confirmation_details) ") + + try: + ccd = CeasedConfirmationDetails.by_description(self.criteria_value.lower()) + if ccd in ( + CeasedConfirmationDetails.NULL, + CeasedConfirmationDetails.NOT_NULL, + ): + self.sql_where.append(f" IS {ccd.get_description()} ") + else: + raise ValueError("Unrecognized enum value") + except Exception: + # Fall back to string matching + value_quoted = f"'{self.criteria_value.lower()}'" + self.sql_where.append(f"{self.criteria_comparator} {value_quoted} ") + + def _add_criteria_ceased_confirmation_user_id(self, user: "User") -> None: + self.sql_where.append(" AND ss.ceased_confirmation_pio_id ") + + if self.criteria_value.isnumeric(): # actual PIO ID + self.sql_where.append(self.criteria_comparator) + self.sql_where.append(self.criteria_value) + self.sql_where.append(" ") + else: + try: + enum_value = CeasedConfirmationUserId.by_description( + self.criteria_value.lower() + ) + if enum_value == CeasedConfirmationUserId.AUTOMATED_PROCESS_ID: + self.sql_where.append(self.criteria_comparator) + self.sql_where.append(" 2 ") + elif enum_value == CeasedConfirmationUserId.NOT_NULL: + self.sql_where.append(self._SQL_IS_NOT_NULL) + elif enum_value == CeasedConfirmationUserId.NULL: + self.sql_where.append(self._SQL_IS_NULL) + elif enum_value == CeasedConfirmationUserId.USER_ID: + self.sql_where.append(self.criteria_comparator) + self.sql_where.append(str(user.user_id) + " ") + else: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) + except Exception: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) + + def _add_criteria_clinical_reason_for_cease(self) -> None: + try: + clinical_cease_reason = ( + ClinicalCeaseReasonType.by_description_case_insensitive( + self.criteria_value + ) + ) + self.sql_where.append(" AND ss.clinical_reason_for_cease_id ") + + if clinical_cease_reason is None: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) + + if clinical_cease_reason in { + ClinicalCeaseReasonType.NULL, + ClinicalCeaseReasonType.NOT_NULL, + }: + self.sql_where.append(f" IS {clinical_cease_reason.description}") + else: + self.sql_where.append( + f"{self.criteria_comparator}{clinical_cease_reason.valid_value_id}" + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_subject_has_event_status(self) -> None: + event_exists = ( + self.criteria_key == SubjectSelectionCriteriaKey.SUBJECT_HAS_EVENT_STATUS + ) + + try: + event_status = EventStatusType.get_by_code(self.criteria_value.upper()) + + if event_status is None: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) + + self.sql_where.append(" AND") + + if not event_exists: + self.sql_where.append(" NOT") + + self.sql_where.append( + f" EXISTS (" + f" SELECT 1" + f" FROM ep_events_t sev" + f" WHERE sev.screening_subject_id = ss.screening_subject_id" + f" AND sev.event_status_id = {event_status.id}" + f") " + ) + + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + def _add_criteria_has_unprocessed_sspi_updates(self) -> None: + try: + value = HasUnprocessedSSPIUpdates.by_description( + self.criteria_value.lower() + ) + if value == HasUnprocessedSSPIUpdates.YES: + self.sql_where.append(" AND EXISTS ( SELECT 'sdfp' ") + elif value == HasUnprocessedSSPIUpdates.NO: + self.sql_where.append(" AND NOT EXISTS ( SELECT 'sdfp' ") + else: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + self.sql_where.append(" FROM sd_feed_processing_t sdfp ") + self.sql_where.append(" WHERE sdfp.contact_id = c.contact_id ") + self.sql_where.append(" AND sdfp.awaiting_manual_intervention = 'Y' ) ") + + def _add_criteria_has_user_dob_update(self) -> None: + try: + value = HasUserDobUpdate.by_description(self.criteria_value.lower()) + if value == HasUserDobUpdate.YES: + self.sql_where.append(" AND EXISTS ( SELECT 'div' ") + elif value == HasUserDobUpdate.NO: + self.sql_where.append(" AND NOT EXISTS ( SELECT 'div' ") + else: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + self.sql_where.append(" from mpi.sd_data_item_value_t div ") + self.sql_where.append(" WHERE div.contact_id = c.contact_id ") + self.sql_where.append(" AND div.data_item_id = 4 ) ") + + def _add_criteria_subject_has_episodes( + self, episode_type: Optional["EpisodeType"] = None + ) -> None: + try: + value = SubjectHasEpisode.by_description(self.criteria_value.lower()) + if value == SubjectHasEpisode.YES: + self.sql_where.append(" AND EXISTS ( SELECT 'ep' ") + elif value == SubjectHasEpisode.NO: + self.sql_where.append(" AND NOT EXISTS ( SELECT 'ep' ") + else: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) + except Exception: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + + self.sql_where.append(" FROM ep_subject_episode_t ep ") + self.sql_where.append( + " WHERE ep.screening_subject_id = ss.screening_subject_id " + ) + + if episode_type is not None: + self.sql_where.append( + f" AND ep.episode_type_id = {episode_type.valid_value_id} " + ) + + if self.criteria_key == SubjectSelectionCriteriaKey.SUBJECT_HAS_AN_OPEN_EPISODE: + self.sql_where.append(" AND ep.episode_end_date IS NULL ") + + self.sql_where.append(" )") + + def _get_date_field_column_name(self, pathway: str, date_type: str) -> str: + """ + Map pathway and date_type to the correct Oracle column name. + xt and ap are optional table aliases for diagnostic test and appointment joins. + """ + + concat_key = (pathway + date_type).upper() + + mapping = { + "ALL_PATHWAYSSCREENING_STATUS_CHANGE_DATE": "TRUNC(ss.screening_status_change_date)", + "ALL_PATHWAYSLATEST_EPISODE_START_DATE": "TRUNC(ep.episode_start_date)", + "ALL_PATHWAYSLATEST_EPISODE_END_DATE": "TRUNC(ep.episode_end_date)", + "ALL_PATHWAYSCEASED_CONFIRMATION_DATE": "TRUNC(ss.ceased_confirmation_recd_date)", + "ALL_PATHWAYSDATE_OF_DEATH": "TRUNC(c.date_of_death)", + "ALL_PATHWAYSSYMPTOMATIC_PROCEDURE_DATE": f"TRUNC({self.xt}.surgery_date)", + "ALL_PATHWAYSAPPOINTMENT_DATE": f"TRUNC({self.ap}.appointment_date)", + "FOBTDUE_DATE": "TRUNC(ss.screening_due_date)", + "FOBTCALCULATED_DUE_DATE": "TRUNC(ss.calculated_sdd)", + "FOBTDUE_DATE_CHANGE_DATE": "TRUNC(ss.sdd_change_date)", + "FOBTPREVIOUS_DUE_DATE": "TRUNC(ss.previous_sdd)", + "SURVEILLANCEDUE_DATE": "TRUNC(ss.surveillance_screen_due_date)", + "SURVEILLANCECALCULATED_DUE_DATE": "TRUNC(ss.calculated_ssdd)", + "SURVEILLANCEDUE_DATE_CHANGE_DATE": "TRUNC(ss.surveillance_sdd_change_date)", + "SURVEILLANCEPREVIOUS_DUE_DATE": "TRUNC(ss.previous_surveillance_sdd)", + "LYNCHDUE_DATE": "TRUNC(ss.lynch_screening_due_date)", + "LYNCHCALCULATED_DUE_DATE": "TRUNC(ss.lynch_calculated_sdd)", + "LYNCHDUE_DATE_CHANGE_DATE": "TRUNC(ss.lynch_sdd_change_date)", + "LYNCHDIAGNOSIS_DATE": "TRUNC(gcd.diagnosis_date)", + "LYNCHLAST_COLONOSCOPY_DATE": "TRUNC(gcd.last_colonoscopy_date)", + "LYNCHPREVIOUS_DUE_DATE": "TRUNC(ss.previous_lynch_sdd)", + "LYNCHLOWER_LYNCH_AGE": "pkg_bcss_common.f_get_lynch_lower_age_limit (ss.screening_subject_id)", + "ALL_PATHWAYSSEVENTY_FIFTH_BIRTHDAY": "ADD_MONTHS(TRUNC(c.date_of_birth), 12*75)", + "ALL_PATHWAYSCADS TUMOUR_DATE_OF_DIAGNOSIS": "TRUNC(dctu.date_of_diagnosis)", + "ALL_PATHWAYSCADS TREATMENT_START_DATE": "TRUNC(dctr.treatment_start_date)", + "ALL_PATHWAYSDIAGNOSTIC_TEST_CONFIRMED_DATE": f"TRUNC({self.xt}.confirmed_date)", + } + if concat_key not in mapping: + raise SelectionBuilderException(self.criteria_key_name, self.criteria_value) + return mapping[concat_key] + + def _add_join_to_latest_episode(self) -> None: + if not self.sql_from_episode: + self.sql_from_episode.append( + " INNER JOIN ep_subject_episode_t ep " + " ON ep.screening_subject_id = ss.screening_subject_id " + " AND ep.subject_epis_id = ( " + " SELECT MAX(epx.subject_epis_id) " + " FROM ep_subject_episode_t epx " + " WHERE epx.screening_subject_id = ss.screening_subject_id ) " + ) + + def _add_join_to_genetic_condition_diagnosis(self) -> None: + if not self.sql_from_genetic_condition_diagnosis: + self.sql_from_genetic_condition_diagnosis.append( + " INNER JOIN genetic_condition_diagnosis gcd " + " ON gcd.screening_subject_id = ss.screening_subject_id " + " AND gcd.deleted_flag = 'N' " + ) + + def _add_join_to_cancer_audit_dataset(self) -> None: + if ( + " INNER JOIN ds_cancer_audit_t cads ON cads.episode_id = ep.subject_epis_id AND cads.deleted_flag = 'N' " + not in self.sql_from_cancer_audit_datasets + ): + self.sql_from_cancer_audit_datasets.append( + " INNER JOIN ds_cancer_audit_t cads ON cads.episode_id = ep.subject_epis_id AND cads.deleted_flag = 'N' " + ) + + def _add_join_to_cancer_audit_dataset_tumor(self) -> None: + if ( + " INNER JOIN DS_CA2_TUMOUR dctu ON dctu.CANCER_AUDIT_ID =cads.CANCER_AUDIT_ID AND dctu.deleted_flag = 'N' " + not in self.sql_from_cancer_audit_datasets + ): + self.sql_from_cancer_audit_datasets.append( + " INNER JOIN DS_CA2_TUMOUR dctu ON dctu.CANCER_AUDIT_ID =cads.CANCER_AUDIT_ID AND dctu.deleted_flag = 'N' " + ) + + def _add_join_to_cancer_audit_dataset_treatment(self) -> None: + if ( + " INNER JOIN DS_CA2_TREATMENT dctr ON dctr.CANCER_AUDIT_ID = cads.CANCER_AUDIT_ID AND dctr.deleted_flag = 'N' " + not in self.sql_from_cancer_audit_datasets + ): + self.sql_from_cancer_audit_datasets.append( + " INNER JOIN DS_CA2_TREATMENT dctr ON dctr.CANCER_AUDIT_ID = cads.CANCER_AUDIT_ID AND dctr.deleted_flag = 'N' " + ) + + def _add_check_comparing_one_date_with_another( + self, + column_to_check: str, + comparator: str, + date_to_check_against: str, + allow_nulls: bool, + ) -> None: + if allow_nulls: + column_to_check = self._nvl_date(column_to_check) + date_to_check_against = self._nvl_date(date_to_check_against) + self.sql_where.append( + f" AND {column_to_check} {comparator} {date_to_check_against} " + ) + + def _add_days_to_oracle_date(self, column_name: str, number_of_days: str) -> str: + return f" TRUNC({column_name}) + {number_of_days} " + + def _add_months_to_oracle_date( + self, column_name: str, number_of_months: str + ) -> str: + return self._add_months_or_years_to_oracle_date( + column_name, False, number_of_months + ) + + def _add_years_to_oracle_date(self, column_name: str, number_of_years) -> str: + return self._add_months_or_years_to_oracle_date( + column_name, True, number_of_years + ) + + def _add_months_or_years_to_oracle_date( + self, column_name: str, years: bool, number_to_add_or_subtract: str + ) -> str: + if years: + number_to_add_or_subtract += " * 12 " + return f" ADD_MONTHS(TRUNC({column_name}), {number_to_add_or_subtract}) " + + def _subtract_days_from_oracle_date( + self, column_name: str, number_of_days: str + ) -> str: + return f" TRUNC({column_name}) - {number_of_days} " + + def _subtract_months_from_oracle_date( + self, column_name: str, number_of_months: str + ) -> str: + return self._add_months_or_years_to_oracle_date( + column_name, False, "-" + number_of_months + ) + + def _subtract_years_from_oracle_date( + self, column_name: str, number_of_years: str + ) -> str: + return self._add_months_or_years_to_oracle_date( + column_name, True, "-" + number_of_years + ) + + def _oracle_to_date_method(self, date: str, format: str) -> str: + return f" TO_DATE( '{date}', '{format}') " + + def _add_check_date_is_a_period_ago_or_later( + self, date_column_name: str, value: str + ) -> None: + criteria_words = value.strip().lower().split() + comparator, numerator, denominator, ago_or_later = ( + self._extract_date_comparison_components(criteria_words) + ) + + compound = f"{denominator} {ago_or_later}" + + getter_map = { + "year ago": self._get_x_years_ago, + "years ago": self._get_x_years_ago, + "year later": self._get_x_years_later, + "years later": self._get_x_years_later, + "month ago": self._get_x_months_ago, + "months ago": self._get_x_months_ago, + "month later": self._get_x_months_later, + "months later": self._get_x_months_later, + "day ago": self._get_x_days_ago, + "days ago": self._get_x_days_ago, + "day later": self._get_x_days_later, + "days later": self._get_x_days_later, + } + + if compound in getter_map: + getter_map[compound](date_column_name, comparator, numerator) + + if comparator == " > ": + post_condition = " <= " if ago_or_later == "ago" else " > " + self._add_check_comparing_one_date_with_another( + date_column_name, post_condition, self._TRUNC_SYSDATE, False + ) + + def _extract_date_comparison_components( + self, words: list[str] + ) -> tuple[str, str, str, str]: + value = " ".join(words) + default_comp = " = " + mappings = { + "ago": { + ">": (" < ", 1), + ">=": (" <= ", 1), + "more than": (" < ", 2), + "<": (" > ", 1), + "<=": (" >= ", 1), + "less than": (" > ", 2), + }, + "later": { + ">": (" > ", 1), + ">=": (" >= ", 1), + "more than": (" > ", 2), + "<": (" < ", 1), + "<=": (" <= ", 1), + "less than": (" < ", 2), + }, + } + + for direction in ("ago", "later"): + if words[-1] == direction: + for prefix, (comp, offset) in mappings[direction].items(): + if value.startswith(prefix): + return comp, words[offset], words[offset + 1], direction + return default_comp, words[0], words[1], direction + + return default_comp, words[0], words[1], words[-1] + + def _add_criteria_date_field_special_cases( + self, + value: str, + subject: "Subject", + pathway: str, + date_type: str, + date_column_name: str, + ) -> None: + try: + date_to_use = DateDescription.by_description_case_insensitive(value) + if date_to_use is None: + raise ValueError(f"No DateDescription found for value: {value}") + number_of_months = str(date_to_use.number_of_months) + + match date_to_use: + case DateDescription.NOT_NULL: + self._add_check_column_is_null_or_not(date_column_name, False) + case DateDescription.NULL | DateDescription.UNCHANGED_NULL: + self._add_check_column_is_null_or_not(date_column_name, True) + case DateDescription.LAST_BIRTHDAY: + self._add_check_comparing_one_date_with_another( + date_column_name, + " = ", + "pkg_bcss_common.f_get_last_birthday(c.date_of_birth)", + False, + ) + case ( + DateDescription.CSDD + | DateDescription.CALCULATED_FOBT_DUE_DATE + | DateDescription.CALCULATED_SCREENING_DUE_DATE + ): + self._add_check_comparing_one_date_with_another( + date_column_name, " = ", "TRUNC(ss.calculated_sdd)", True + ) + case DateDescription.CALCULATED_LYNCH_DUE_DATE: + self._add_check_comparing_one_date_with_another( + date_column_name, " = ", "TRUNC(ss.lynch_calculated_sdd)", True + ) + case ( + DateDescription.CALCULATED_SURVEILLANCE_DUE_DATE + | DateDescription.CSSDD + ): + self._add_check_comparing_one_date_with_another( + date_column_name, " = ", "TRUNC(ss.calculated_ssdd)", True + ) + case DateDescription.LESS_THAN_TODAY | DateDescription.BEFORE_TODAY: + self._add_check_comparing_one_date_with_another( + date_column_name, " < ", self._TRUNC_SYSDATE, False + ) + case DateDescription.GREATER_THAN_TODAY | DateDescription.AFTER_TODAY: + self._add_check_comparing_one_date_with_another( + date_column_name, " > ", self._TRUNC_SYSDATE, False + ) + case DateDescription.TODAY: + self._add_check_comparing_one_date_with_another( + date_column_name, " = ", self._TRUNC_SYSDATE, False + ) + case DateDescription.TOMORROW: + self._add_check_comparing_one_date_with_another( + date_column_name, + " = ", + self._add_days_to_oracle_date("SYSDATE", "1"), + False, + ) + case DateDescription.YESTERDAY: + self._add_check_comparing_one_date_with_another( + date_column_name, + " = ", + self._subtract_days_from_oracle_date("SYSDATE", "1"), + False, + ) + case ( + DateDescription.LESS_THAN_OR_EQUAL_TO_6_MONTHS_AGO + | DateDescription.WITHIN_THE_LAST_2_YEARS + | DateDescription.WITHIN_THE_LAST_4_YEARS + | DateDescription.WITHIN_THE_LAST_6_MONTHS + ): + self._add_check_comparing_one_date_with_another( + self._subtract_months_from_oracle_date( + "SYSDATE", number_of_months + ), + " <= ", + date_column_name, + False, + ) + self._add_check_comparing_one_date_with_another( + date_column_name, " <= ", self._TRUNC_SYSDATE, False + ) + case DateDescription.LYNCH_DIAGNOSIS_DATE: + self._add_join_to_genetic_condition_diagnosis() + self._add_check_comparing_one_date_with_another( + date_column_name, " = ", "TRUNC(gcd.diagnosis_date)", True + ) + case DateDescription.TWO_YEARS_FROM_LAST_LYNCH_COLONOSCOPY_DATE: + self._add_join_to_genetic_condition_diagnosis() + self._add_check_comparing_one_date_with_another( + date_column_name, + " = ", + self._add_months_to_oracle_date( + "gcd.last_colonoscopy_date", number_of_months + ), + False, + ) + case ( + DateDescription.ONE_YEAR_FROM_EPISODE_END + | DateDescription.TWO_YEARS_FROM_EPISODE_END + | DateDescription.THREE_YEARS_FROM_EPISODE_END + ): + self._add_check_comparing_one_date_with_another( + date_column_name, + " = ", + self._add_months_to_oracle_date( + "ep.episode_end_date", number_of_months + ), + False, + ) + case ( + DateDescription.ONE_YEAR_FROM_DIAGNOSTIC_TEST + | DateDescription.TWO_YEARS_FROM_DIAGNOSTIC_TEST + | DateDescription.THREE_YEARS_FROM_DIAGNOSTIC_TEST + ): + self._add_check_comparing_one_date_with_another( + date_column_name, + " = ", + self._add_months_to_oracle_date( + self.xt + ".confirmed_date", number_of_months + ), + False, + ) + case ( + DateDescription.ONE_YEAR_FROM_SYMPTOMATIC_PROCEDURE + | DateDescription.TWO_YEARS_FROM_SYMPTOMATIC_PROCEDURE + | DateDescription.THREE_YEARS_FROM_SYMPTOMATIC_PROCEDURE + ): + self._add_check_comparing_one_date_with_another( + date_column_name, + " = ", + self._add_months_to_oracle_date( + self.xt + ".surgery_date", number_of_months + ), + False, + ) + case DateDescription.TWO_YEARS_FROM_EARLIEST_S10_EVENT: + self._add_check_comparing_date_with_earliest_or_latest_event_date( + date_column_name, + " = ", + "MIN", + EventStatusType.S10, + number_of_months, + ) + case DateDescription.TWO_YEARS_FROM_LATEST_A37_EVENT: + self._add_check_comparing_date_with_earliest_or_latest_event_date( + date_column_name, + " = ", + "MAX", + EventStatusType.A37, + number_of_months, + ) + case DateDescription.TWO_YEARS_FROM_LATEST_J8_EVENT: + self._add_check_comparing_date_with_earliest_or_latest_event_date( + date_column_name, + " = ", + "MAX", + EventStatusType.J8, + number_of_months, + ) + case DateDescription.TWO_YEARS_FROM_LATEST_J15_EVENT: + self._add_check_comparing_date_with_earliest_or_latest_event_date( + date_column_name, + " = ", + "MAX", + EventStatusType.J15, + number_of_months, + ) + case DateDescription.TWO_YEARS_FROM_LATEST_J16_EVENT: + self._add_check_comparing_date_with_earliest_or_latest_event_date( + date_column_name, + " = ", + "MAX", + EventStatusType.J16, + number_of_months, + ) + case DateDescription.TWO_YEARS_FROM_LATEST_J25_EVENT: + self._add_check_comparing_date_with_earliest_or_latest_event_date( + date_column_name, + " = ", + "MAX", + EventStatusType.J25, + number_of_months, + ) + case DateDescription.TWO_YEARS_FROM_LATEST_S158_EVENT: + self._add_check_comparing_date_with_earliest_or_latest_event_date( + date_column_name, + " = ", + "MAX", + EventStatusType.S158, + number_of_months, + ) + case DateDescription.AS_AT_EPISODE_START: + self._add_join_to_latest_episode() + self._add_check_comparing_one_date_with_another( + date_column_name, " = ", "TRUNC(ep.episode_start_dd)", False + ) + case DateDescription.UNCHANGED: + existing_due_date_value = self._get_date_field_existing_value( + subject, pathway, date_type + ) + if subject is None: + raise ValueError("Subject is None") + elif existing_due_date_value is None: + self._add_check_column_is_null_or_not(date_column_name, True) + elif existing_due_date_value == date(1066, 1, 1): + raise ValueError(f"{value} date doesn't support 'unchanged'") + else: + self._add_check_comparing_one_date_with_another( + date_column_name, + " = ", + self._oracle_to_date_method( + existing_due_date_value.strftime("%Y-%m-%d"), + "yyyy-mm-dd", + ), + False, + ) + + except Exception as e: + raise SelectionBuilderException( + self.criteria_key_name, self.criteria_value + ) from e + + def _add_check_comparing_date_with_earliest_or_latest_event_date( + self, + date_column_name: str, + comparator: str, + min_or_max: str, + event: EventStatusType, + number_of_months: str, + ): + + self._add_join_to_latest_episode() + + alias = event.code.lower() + subquery = ( + f"(SELECT {self._add_months_to_oracle_date(f'{min_or_max}({alias}.datestamp)', number_of_months)} " + f"FROM ep_events_t {alias} " + f"WHERE {alias}.subject_epis_id = ep.subject_epis_id " + f"AND {alias}.event_status_id = {event.id})" + ) + + self.sql_where.append(f"AND {date_column_name} {comparator} {subquery}") + + def _add_check_column_is_null_or_not(self, column_name: str, is_null: bool) -> None: + self.sql_where.append(f" AND {column_name} ") + if is_null: + self.sql_where.append(self._SQL_IS_NULL) + else: + self.sql_where.append(self._SQL_IS_NOT_NULL) + + def _get_date_field_existing_value( + self, subject: "Subject", pathway: str, date_type: str + ) -> Optional[date]: + + key = pathway + date_type + + if key == "ALL_PATHWAYS" + "SCREENING_STATUS_CHANGE_DATE": + return subject.screening_status_change_date + elif key == "ALL_PATHWAYS" + "DATE_OF_DEATH": + return subject.date_of_death + elif key == "FOBT" + "DUE_DATE": + return subject.screening_due_date + elif key == "FOBT" + "CALCULATED_DUE_DATE": + return subject.calculated_screening_due_date + elif key == "FOBT" + "DUE_DATE_CHANGE_DATE": + return subject.screening_due_date_change_date + elif key == "SURVEILLANCE" + "DUE_DATE": + return subject.surveillance_screening_due_date + elif key == "SURVEILLANCE" + "CALCULATED_DUE_DATE": + return subject.calculated_surveillance_due_date + elif key == "SURVEILLANCE" + "DUE_DATE_CHANGE_DATE": + return subject.surveillance_due_date_change_date + elif key == "LYNCH" + "DUE_DATE": + return subject.lynch_due_date + elif key == "LYNCH" + "CALCULATED_DUE_DATE": + return subject.calculated_lynch_due_date + elif key == "LYNCH" + "DUE_DATE_CHANGE_DATE": + return subject.lynch_due_date_change_date + else: + return date(1066, 1, 1) + + def _get_x_years_ago( + self, date_column_name: str, comparator: str, numerator: str + ) -> None: + self._add_check_comparing_one_date_with_another( + date_column_name, + comparator, + self._subtract_years_from_oracle_date("SYSDATE", numerator), + False, + ) + + def _get_x_months_ago( + self, date_column_name: str, comparator: str, numerator: str + ) -> None: + self._add_check_comparing_one_date_with_another( + date_column_name, + comparator, + self._subtract_months_from_oracle_date("SYSDATE", numerator), + False, + ) + + def _get_x_days_ago( + self, date_column_name: str, comparator: str, numerator: str + ) -> None: + self._add_check_comparing_one_date_with_another( + date_column_name, + comparator, + self._subtract_days_from_oracle_date("SYSDATE", numerator), + False, + ) + + def _get_x_years_later( + self, date_column_name: str, comparator: str, numerator: str + ) -> None: + self._add_check_comparing_one_date_with_another( + date_column_name, + comparator, + self._add_years_to_oracle_date("SYSDATE", numerator), + False, + ) + + def _get_x_months_later( + self, date_column_name: str, comparator: str, numerator: str + ) -> None: + self._add_check_comparing_one_date_with_another( + date_column_name, + comparator, + self._add_months_to_oracle_date("SYSDATE", numerator), + False, + ) + + def _get_x_days_later( + self, date_column_name: str, comparator: str, numerator: str + ) -> None: + self._add_check_comparing_one_date_with_another( + date_column_name, + comparator, + self._add_days_to_oracle_date("SYSDATE", numerator), + False, + ) + + def _nvl_date(self, column_name: str) -> str: + if "SYSDATE" in column_name.upper(): + return_value = " " + column_name + " " + else: + return_value = ( + " NVL(" + column_name + ", TO_DATE('01/01/1066', 'dd/mm/yyyy')) " + ) + return return_value + + def _is_valid_date(self, value: str, date_format: str = "%Y-%m-%d") -> bool: + try: + datetime.strptime(value, date_format) + return True + except ValueError: + return False + + @staticmethod + def single_quoted(value: str) -> str: + return f"'{value}'" + + @staticmethod + def invalid_use_of_unchanged_exception(criteria_key_name: str, reason: str): + return SelectionBuilderException( + f"Invalid use of 'unchanged' criteria value ({reason}) for: {criteria_key_name}" + ) diff --git a/utils/pdf_reader.py b/utils/pdf_reader.py new file mode 100644 index 00000000..f3bf9985 --- /dev/null +++ b/utils/pdf_reader.py @@ -0,0 +1,28 @@ +from pypdf import PdfReader +import pandas as pd + + +def extract_nhs_no_from_pdf(file: str) -> pd.DataFrame: + """ + Extracts all of the NHS Numbers in a PDF file and stores them in a pandas DataFrame. + + Args: + file (str): The file path stored as a string. + + Returns: + nhs_no_df (pd.DataFrame): A DataFrame with the column 'subject_nhs_number' and each NHS number being a record + """ + reader = PdfReader(file) + nhs_no_df = pd.DataFrame(columns=["subject_nhs_number"]) + # For loop looping through all pages of the file to find the NHS Number + for i, pages in enumerate(reader.pages): + text = pages.extract_text() + if "NHS No" in text: + # If NHS number is found split the text by every new line into a list + text = text.splitlines(True) + for split_text in text: + if "NHS No" in split_text: + # If a string is found containing "NHS No" only digits are stored into nhs_no + nhs_no = "".join([ele for ele in split_text if ele.isdigit()]) + nhs_no_df.loc[i] = [nhs_no] + return nhs_no_df diff --git a/utils/screening_subject_page_searcher.py b/utils/screening_subject_page_searcher.py new file mode 100644 index 00000000..c0bba746 --- /dev/null +++ b/utils/screening_subject_page_searcher.py @@ -0,0 +1,232 @@ +from pages.base_page import BasePage +from pages.screening_subject_search.subject_screening_search_page import ( + SubjectScreeningPage, + SearchAreaSearchOptions, +) +from pages.screening_subject_search.subject_screening_summary_page import ( + SubjectScreeningSummaryPage, +) +from playwright.sync_api import Page, expect + + +def verify_subject_event_status_by_nhs_no( + page: Page, nhs_no: str, latest_event_status: str | list +) -> None: + """ + This is used to check that the latest event status of a subject has been updated to what is expected + We provide the NHS Number for the subject and the expected latest event status and it navigates to the correct page + From here it searches for that subject against the whole database and verifies the latest event status is as expected + + Args: + page (Page): This is the playwright page object + nhs_no (str): The screening subject's nhs number + latest_event_status (str | list): the screening subjects's latest event status + """ + BasePage(page).click_main_menu_link() + BasePage(page).go_to_screening_subject_search_page() + SubjectScreeningPage(page).click_nhs_number_filter() + SubjectScreeningPage(page).nhs_number_filter.fill(nhs_no) + SubjectScreeningPage(page).nhs_number_filter.press("Tab") + SubjectScreeningPage(page).select_search_area_option( + SearchAreaSearchOptions.SEARCH_AREA_WHOLE_DATABASE.value + ) + SubjectScreeningPage(page).click_search_button() + SubjectScreeningSummaryPage(page).verify_subject_screening_summary() + SubjectScreeningSummaryPage(page).verify_latest_event_status_header() + SubjectScreeningSummaryPage(page).verify_latest_event_status_value( + latest_event_status + ) + + +def search_subject_by_nhs_number(page: Page, nhs_number: str) -> None: + """ + This searches for a subject by their NHS Number and checks the page has redirected accordingly + + Args: + page (Page): This is the playwright page object + nhs_no (str): The screening subject's nhs number + """ + SubjectScreeningPage(page).click_clear_filters_button() + SubjectScreeningPage(page).nhs_number_filter.fill(nhs_number) + SubjectScreeningPage(page).nhs_number_filter.press("Tab") + SubjectScreeningPage(page).click_search_button() + + +def search_subject_by_surname(page: Page, surname: str) -> None: + """ + This searches for a subject by their surname and checks the page has redirected accordingly + + Args: + page (Page): This is the playwright page object + surname (str): The screening subject's surname + """ + SubjectScreeningPage(page).click_clear_filters_button() + SubjectScreeningPage(page).surname_filter.fill(surname) + SubjectScreeningPage(page).surname_filter.press("Tab") + SubjectScreeningPage(page).click_search_button() + + +def search_subject_by_forename(page: Page, forename: str) -> None: + """ + This searches for a subject by their forename and checks the page has redirected accordingly + + Args: + page (Page): This is the playwright page object + forename (str): The screening subject's forename + """ + SubjectScreeningPage(page).click_clear_filters_button() + SubjectScreeningPage(page).forename_filter.fill(forename) + SubjectScreeningPage(page).forename_filter.press("Tab") + SubjectScreeningPage(page).click_search_button() + + +def search_subject_by_dob(page: Page, dob: str) -> None: + """ + This searches for a subject by their date of birth and checks the page has redirected accordingly + + Args: + page (Page): This is the playwright page object + dob (str): The screening subject's date of birth + """ + SubjectScreeningPage(page).click_clear_filters_button() + SubjectScreeningPage(page).date_of_birth_filter.fill(dob) + SubjectScreeningPage(page).date_of_birth_filter.press("Tab") + SubjectScreeningPage(page).click_search_button() + + +def search_subject_by_postcode(page: Page, postcode: str) -> None: + """ + This searches for a subject by their postcode and checks the page has redirected accordingly + + Args: + page (Page): This is the playwright page object + postcode (str): The screening subject's postcode + """ + SubjectScreeningPage(page).click_clear_filters_button() + SubjectScreeningPage(page).postcode_filter.fill(postcode) + SubjectScreeningPage(page).postcode_filter.press("Tab") + SubjectScreeningPage(page).click_search_button() + + +def search_subject_by_episode_closed_date(page: Page, episode_closed_date: str) -> None: + """ + This searches for a subject by their episode closed date and checks the page has redirected accordingly + + Args: + page (Page): This is the playwright page object + episode_closed_date (str): The screening subject's episode closed date + """ + SubjectScreeningPage(page).click_clear_filters_button() + SubjectScreeningPage(page).episode_closed_date_filter.fill(episode_closed_date) + SubjectScreeningPage(page).episode_closed_date_filter.press("Tab") + SubjectScreeningPage(page).click_search_button() + + +def search_subject_by_status(page: Page, status: str) -> None: + """ + This searches for a subject by their screening status and checks the page has redirected accordingly + + Args: + page (Page): This is the playwright page object + status (str): The screening subject's screening status + """ + SubjectScreeningPage(page).click_clear_filters_button() + SubjectScreeningPage(page).select_screening_status_options(status) + SubjectScreeningPage(page).select_screening_status.press("Tab") + SubjectScreeningPage(page).click_search_button() + + +def search_subject_by_latest_event_status(page: Page, status: str) -> None: + """ + This searches for a subject by their latest event status and checks the page has redirected accordingly + + Args: + page (Page): This is the playwright page object + status (str): The screening subject's latest event status + """ + SubjectScreeningPage(page).click_clear_filters_button() + SubjectScreeningPage(page).select_episode_status_option(status) + SubjectScreeningPage(page).select_episode_status.press("Tab") + SubjectScreeningPage(page).click_search_button() + + +def search_subject_by_search_area( + page: Page, + status: str, + search_area: str, + code: str | None = None, + gp_practice_code: str | None = None, +) -> None: + """ + This searches for a subject by the search area, populating necessary fields were needed, and checks that the page has redirected accordingly + + Args: + page (Page): This is the playwright page object + status (str): The screening subject's screening status + search_area (str): This is the search area option to use + code (str): If provided, the code parameter is used to fill the appropriate code filter field + gp_practice_code (str): If provided, the GP practice code parameter is used to fill the GP Practice in CCG filter field + """ + SubjectScreeningPage(page).click_clear_filters_button() + SubjectScreeningPage(page).select_screening_status_options(status) + SubjectScreeningPage(page).select_screening_status.press("Tab") + SubjectScreeningPage(page).select_search_area_option(search_area) + SubjectScreeningPage(page).select_search_area.press("Tab") + if code != None: + SubjectScreeningPage(page).appropriate_code_filter.fill(code) + SubjectScreeningPage(page).appropriate_code_filter.press("Tab") + if gp_practice_code != None: + SubjectScreeningPage(page).gp_practice_in_ccg_filter.fill(gp_practice_code) + SubjectScreeningPage(page).gp_practice_in_ccg_filter.press("Tab") + SubjectScreeningPage(page).click_search_button() + + +def check_clear_filters_button_works(page: Page, nhs_number: str) -> None: + """ + This checks that the "clear filter" button works as intended + + Args: + page (Page): This is the playwright page object + nhs_number (str): The screening subject's nhs number + """ + SubjectScreeningPage(page).nhs_number_filter.fill(nhs_number) + expect(SubjectScreeningPage(page).nhs_number_filter).to_have_value(nhs_number) + SubjectScreeningPage(page).click_clear_filters_button() + expect(SubjectScreeningPage(page).nhs_number_filter).to_be_empty() + + +def search_subject_demographics_by_nhs_number(page: Page, nhs_number: str) -> None: + """ + This searches for a subject by their NHS Number and checks the page has redirected accordingly + + Args: + page (Page): This is the playwright page object + nhs_number (str): The screening subject's nhs number + """ + SubjectScreeningPage(page).click_clear_filters_button() + SubjectScreeningPage(page).click_demographics_filter() + SubjectScreeningPage(page).click_nhs_number_filter() + SubjectScreeningPage(page).nhs_number_filter.fill(nhs_number) + SubjectScreeningPage(page).nhs_number_filter.press("Tab") + SubjectScreeningPage(page).select_search_area_option( + SearchAreaSearchOptions.SEARCH_AREA_WHOLE_DATABASE.value + ) + SubjectScreeningPage(page).click_search_button() + + +def search_subject_episode_by_nhs_number(page: Page, nhs_number: str) -> None: + """ + This searches for a subject by their NHS Number and checks the page has redirected accordingly + + Args: + page (Page): This is the playwright page object + nhs_no (str): The screening subject's nhs number + """ + SubjectScreeningPage(page).click_clear_filters_button() + SubjectScreeningPage(page).click_episodes_filter() + SubjectScreeningPage(page).nhs_number_filter.fill(nhs_number) + SubjectScreeningPage(page).nhs_number_filter.press("Tab") + SubjectScreeningPage(page).select_search_area_option( + SearchAreaSearchOptions.SEARCH_AREA_WHOLE_DATABASE.value + ) + SubjectScreeningPage(page).click_search_button() diff --git a/utils/subject_demographics.py b/utils/subject_demographics.py new file mode 100644 index 00000000..a5758666 --- /dev/null +++ b/utils/subject_demographics.py @@ -0,0 +1,103 @@ +from playwright.sync_api import Page +from datetime import datetime +from faker import Faker +from dateutil.relativedelta import relativedelta +import logging +from pages.base_page import BasePage +from pages.screening_subject_search.subject_screening_search_page import ( + SubjectScreeningPage, + SearchAreaSearchOptions, +) +from pages.screening_subject_search.subject_demographic_page import ( + SubjectDemographicPage, +) + + +class SubjectDemographicUtil: + """The class for holding all the util methods to be used on the subject demographic page""" + + def __init__(self, page: Page): + self.page = page + + def update_subject_dob( + self, + nhs_no: str, + random_dob: bool = False, + younger_subject: bool | None = None, + new_dob: datetime | None = None, + ) -> None: + """ + Navigates to the subject demographics page and updates a subject's date of birth. + + Args: + nhs_no (str): The NHS number of the subject you want to update. + younger_subject (bool): Whether you want the subject to be younger (50-70) or older (75-100). + """ + if random_dob: + date = self.random_dob_within_range( + younger_subject if younger_subject is not None else False + ) + else: + date = new_dob + + logging.info(f"Navigating to subject demographic page for: {nhs_no}") + BasePage(self.page).click_main_menu_link() + BasePage(self.page).go_to_screening_subject_search_page() + SubjectScreeningPage(self.page).click_demographics_filter() + SubjectScreeningPage(self.page).click_nhs_number_filter() + SubjectScreeningPage(self.page).nhs_number_filter.fill(nhs_no) + SubjectScreeningPage(self.page).nhs_number_filter.press("Tab") + SubjectScreeningPage(self.page).select_search_area_option( + SearchAreaSearchOptions.SEARCH_AREA_WHOLE_DATABASE.value + ) + SubjectScreeningPage(self.page).click_search_button() + postcode_filled = SubjectDemographicPage(self.page).is_postcode_filled() + if not postcode_filled: + fake = Faker("en_GB") + random_postcode = fake.postcode() + SubjectDemographicPage(self.page).fill_postcode_input(random_postcode) + + current_dob = SubjectDemographicPage(self.page).get_dob_field_value() + logging.info(f"Current DOB: {current_dob}") + if date is not None: + SubjectDemographicPage(self.page).fill_dob_input(date) + else: + raise ValueError("Date of birth is None. Cannot fill DOB input.") + SubjectDemographicPage(self.page).click_update_subject_data_button() + updated_dob = SubjectDemographicPage(self.page).get_dob_field_value() + logging.info(f"Updated DOB: {updated_dob}") + + def random_dob_within_range(self, younger: bool) -> datetime: + """ + Generate a random date of birth within a specified range. + + Args: + younger (bool): If True, generate a date of birth for a subject aged between 50 and 70. + If False, generate a date of birth for a subject aged between 75 and 100. + + Returns: + datetime: the newly generated date of birth + """ + if younger: + end_date = datetime.today() - relativedelta(years=50) + start_date = datetime.today() - relativedelta(years=70) + date = self.random_datetime(start_date, end_date) + else: + end_date = datetime.today() - relativedelta(years=75) + start_date = datetime.today() - relativedelta(years=100) + date = self.random_datetime(start_date, end_date) + return date + + def random_datetime(self, start: datetime, end: datetime) -> datetime: + """ + Generate a random datetime between two datetime objects. + + Args: + start (datetime): the starting date + end (datetime): The end date + + Returns: + datetime: the newly generated date + """ + fake = Faker() + return fake.date_time_between(start, end) diff --git a/utils/subject_notes.py b/utils/subject_notes.py new file mode 100644 index 00000000..fc7b1db8 --- /dev/null +++ b/utils/subject_notes.py @@ -0,0 +1,184 @@ +import logging +from playwright.sync_api import Page +import pandas as pd +from typing import Optional +from pages.base_page import BasePage +from pages.screening_subject_search.subject_screening_search_page import ( + SubjectScreeningPage, +) +from pages.screening_subject_search.subject_events_notes import ( + SubjectEventsNotes, +) +from utils.oracle.oracle_specific_functions import ( + get_supporting_notes, +) + + +# Get Supporting notes from DB +def fetch_supporting_notes_from_db( + subjects_df: pd.DataFrame, nhs_no: str, note_status: str +) -> tuple[int, int, pd.DataFrame]: + """ + Retrieves supporting notes from the database using subject and note info. + Args: + subjects_df (pd.DataFrame): Dataframe containing subject information + nhs_no (str): NHS Number of the subject + note_status (str): Status of the note (e.g., active) + + Returns: + Tuple of (screening_subject_id, type_id, notes_df) + """ + logging.info( + f"Retrieving supporting notes for the subject with NHS Number: {nhs_no}." + ) + # Check if the DataFrame is empty + if subjects_df.empty: + raise ValueError(f"No subject data found for NHS Number: {nhs_no}.") + screening_subject_id = int(subjects_df["screening_subject_id"].iloc[0]) + logging.info(f"Screening Subject ID retrieved: {screening_subject_id}") + + type_id = int(subjects_df["type_id"].iloc[0]) + + notes_df = get_supporting_notes(screening_subject_id, type_id, int(note_status)) + logging.info( + f"Retrieved notes for Screening Subject ID: {screening_subject_id}, Type ID: {type_id}." + ) + + return screening_subject_id, type_id, notes_df + + +def verify_note_content_matches_expected( + notes_df: pd.DataFrame, + expected_title: str, + expected_note: str, + type_id: int, +) -> None: + """ + Verifies that the title and note fields from the DataFrame match the expected values. + Args: + notes_df (pd.DataFrame): DataFrame containing the actual note data. + expected_title (str): Expected note title. + expected_note (str): Expected note content. + nhs_no (str): NHS Number of the subject (for logging). + type_id (int): Note type ID (for logging). + Returns: + None + """ + logging.info( + f"Verifying that the title and note match the provided values for type_id: {type_id}." + ) + + actual_title = notes_df["title"].iloc[0].strip() + actual_note = notes_df["note"].iloc[0].strip() + + assert actual_title == expected_title, ( + f"Title does not match. Expected: '{expected_title}', " + f"Found: '{actual_title}'." + ) + assert ( + actual_note == expected_note + ), f"Note does not match. Expected: '{expected_note}', Found: '{actual_note}'." + + +def verify_note_content_ui_vs_db( + page: Page, + notes_df: pd.DataFrame, + row_index: int = 2, + title_prefix_to_strip: Optional[str] = None, +) -> None: + """ + Verifies that the note title and content from the UI match the database values. + + Args: + page (Page): The page object to interact with the UI. + notes_df (pd.DataFrame): DataFrame containing note data from the database. + row_index (int): The row index in the UI table to fetch data from (default is 2). + title_prefix_to_strip (str, optional): Optional prefix to remove from the UI title (e.g., "Subject Kit Note -"). + + Returns: + None + """ + ui_data = SubjectEventsNotes(page).get_title_and_note_from_row(row_index) + logging.info(f"Data from UI: {ui_data}") + + if title_prefix_to_strip: + ui_data["title"] = ui_data["title"].replace(title_prefix_to_strip, "").strip() + logging.info(f"Data from UI after title normalization: {ui_data}") + + db_data = { + "title": notes_df["title"].iloc[0].strip(), + "note": notes_df["note"].iloc[0].strip(), + } + logging.info(f"Data from DB: {db_data}") + + assert ( + ui_data["title"] == db_data["title"] + ), f"Title does not match. UI: '{ui_data['title']}', DB: '{db_data['title']}'" + + assert ( + ui_data["note"] == db_data["note"] + ), f"Note does not match. UI: '{ui_data['note']}', DB: '{db_data['note']}'" + + +def verify_note_removal_and_obsolete_transition( + subjects_df: pd.DataFrame, + ui_data: dict, + general_properties: dict, + note_type_key: str, + status_active_key: str, + status_obsolete_key: str, +) -> None: + """ + Verifies that a note was removed from active notes and appears in obsolete notes. + + Args: + subjects_df (pd.DataFrame): DataFrame with subject details. + ui_data (dict): Dictionary with 'title' and 'note' from the UI. + general_properties (dict): Dictionary with environment settings. + note_type_key (str): Key to access the note type from general_properties. + status_active_key (str): Key for active status. + status_obsolete_key (str): Key for obsolete status. + + Returns: + None + """ + screening_subject_id = int(subjects_df["screening_subject_id"].iloc[0]) + logging.info(f"Screening Subject ID retrieved: {screening_subject_id}") + + note_type = general_properties[note_type_key] + note_status_active = general_properties[status_active_key] + note_status_obsolete = general_properties[status_obsolete_key] + + removed_title = ui_data["title"].strip() + removed_note = ui_data["note"].strip() + + # Check active notes + active_notes_df = get_supporting_notes( + screening_subject_id, note_type, note_status_active + ) + logging.info("Checking active notes for removed note presence.") + for _, row in active_notes_df.iterrows(): + db_title = row["title"].strip() + db_note = row["note"].strip() + logging.info(f"Active note: Title='{db_title}', Note='{db_note}'") + assert ( + db_title != removed_title or db_note != removed_note + ), f"❌ Removed note still present in active notes. Title: '{db_title}', Note: '{db_note}'" + + logging.info("✅ Removed note is not present in active notes.") + + # Check obsolete notes + obsolete_notes_df = get_supporting_notes( + screening_subject_id, note_type, note_status_obsolete + ) + logging.info("Verifying presence of removed note in obsolete notes.") + + found = any( + row["title"].strip() == removed_title and row["note"].strip() == removed_note + for _, row in obsolete_notes_df.iterrows() + ) + + assert ( + found + ), f"❌ Removed note NOT found in obsolete list. Title: '{removed_title}', Note: '{removed_note}'" + logging.info("✅ Removed note confirmed in obsolete notes.") diff --git a/utils/table_util.py b/utils/table_util.py new file mode 100644 index 00000000..c71ffd6b --- /dev/null +++ b/utils/table_util.py @@ -0,0 +1,265 @@ +from turtle import title +from playwright.sync_api import Page, Locator, expect +from sqlalchemy import desc +from pages.base_page import BasePage +import logging +import secrets + + +class TableUtils: + """ + A utility class providing functionality around tables in BCSS. + """ + + def __init__(self, page: Page, table_locator: str) -> None: + """ + Initializer for TableUtils. + + Args: + page (playwright.sync_api.Page): The page the table is on. + table_locator (str): The locator value to use to find the table. + + Returns: + A TableUtils object ready to use. + """ + self.page = page + self.table_id = table_locator # Store the table locator as a string + self.table = page.locator( + table_locator + ) # Create a locator object for the table + + def get_column_index(self, column_name: str) -> int: + """ + Finds the column index dynamically based on column name. + Works even if is missing and header is inside . + + Args: + column_name (str): Name of the column (e.g., 'NHS Number') + + Return: + An int (1-based column index or -1 if not found) + """ + # Try to find headers in first + header_row = self.table.locator("thead tr").first + if not header_row.locator("th").count(): + # Fallback: look for header in if is missing or empty + header_row = ( + self.table.locator("tbody tr").filter(has=self.page.locator("th")).first + ) + + headers = header_row.locator("th") + + # Extract first-row headers (general headers) + header_texts = headers.evaluate_all("ths => ths.map(th => th.innerText.trim())") + logging.info(f"First Row Headers Found: {header_texts}") + + # Extract detailed second-row headers if first-row headers seem generic + second_row_headers = self.table.locator( + "thead tr:nth-child(2) th" + ).evaluate_all("ths => ths.map(th => th.innerText.trim())") + # Merge both lists: Prioritize second-row headers if available + if second_row_headers: + header_texts = second_row_headers + + logging.info(f"Second Row Headers Found: {header_texts}") + for index, header in enumerate(header_texts): + if column_name.lower() in header.lower(): + return index + 1 # Convert to 1-based index + return -1 # Column not found + + def click_first_link_in_column(self, column_name: str): + """ + Clicks the first link found in the given column. + + Args: + column_name (str): Name of the column containing links + """ + column_index = self.get_column_index(column_name) + if column_index == -1: + raise ValueError(f"Column '{column_name}' not found in table") + + # Create a dynamic locator for the desired column + link_locator = f"{self.table_id} tbody tr td:nth-child({column_index}) a" + links = self.page.locator(link_locator) + + if links.count() > 0: + links.first.click() + else: + logging.error(f"No links found in column '{column_name}'") + + def click_first_input_in_column(self, column_name: str): + """ + Clicks the first input found in the given column. E.g. Radios + + Args: + column_name (str): Name of the column containing inputs + """ + column_index = self.get_column_index(column_name) + if column_index == -1: + raise ValueError(f"Column '{column_name}' not found in table") + + # Create a dynamic locator for the desired column + input_locator = f"{self.table_id} tbody tr td:nth-child({column_index}) input" + inputs = self.page.locator(input_locator) + + if inputs.count() > 0: + inputs.first.click() + else: + logging.error(f"No inputs found in column '{column_name}'") + + def _format_inner_text(self, data: str) -> dict: + """ + This formats the inner text of a row to make it easier to manage + + Args: + data (str): The .inner_text() of a table row. + + Returns: + A dict with each column item from the row identified with its position. + """ + dict_to_return = {} + split_rows = data.split("\t") + pos = 1 + for item in split_rows: + dict_to_return[pos] = item + pos += 1 + return dict_to_return + + def get_table_headers(self) -> dict: + """ + This retrieves the headers from the table. + + Returns: + A dict with each column item from the header row identified with its position. + """ + headers = self.page.locator(f"{self.table_id} > thead tr").nth(0).inner_text() + return self._format_inner_text(headers) + + def get_row_count(self) -> int: + """ + This returns the total rows visible on the table (on the screen currently) + + Returns: + An int with the total row count. + """ + return self.page.locator(f"{self.table_id} > tbody tr").count() + + def pick_row(self, row_number: int) -> Locator: + """ + This picks a selected row from table + + Args: + row_id (str): The row number of the row to select. + + Returns: + A playwright.sync_api.Locator with the row object. + """ + return self.page.locator(f"{self.table_id} > tbody tr").nth(row_number) + + def pick_random_row(self) -> Locator: + """ + This picks a random row from the visible rows in the table (full row) + + Returns: + A playwright.sync_api.Locator with the row object. + """ + return self.page.locator(f"{self.table_id} > tbody tr").nth( + secrets.randbelow(self.get_row_count()) + ) + + def pick_random_row_number(self) -> int: + """ + This picks a random row from the table in BCSS and returns its position + + Returns: + An int representing a random row on the table. + """ + return secrets.randbelow(self.get_row_count()) + + def get_row_data_with_headers(self, row_number: int) -> dict: + """ + This picks a selected row from table + + Args: + row_number (str): The row number of the row to select. + + Returns: + A dict object with keys representing the headers, and values representing the row contents. + """ + headers = self.get_table_headers() + row_data = self._format_inner_text( + self.page.locator(f"{self.table_id} > tbody tr") + .nth(row_number) + .inner_text() + ) + results = {} + + for key in headers: + results[headers[key]] = row_data[key] + + return results + + def get_full_table_with_headers(self) -> dict: + """ + This returns the full table as a dict of rows, with each entry having a header key / value pair. + NOTE: The row count starts from 1 to represent the first row, not 0. + + Returns: + A dict object with keys representing the rows, with values being a dict representing a header key / column value pair. + """ + full_results = {} + for row in range(self.get_row_count()): + full_results[row + 1] = self.get_row_data_with_headers(row) + return full_results + + def get_cell_value(self, column_name: str, row_index: int) -> str: + """ + Retrieves the text value of a cell at the specified column(name) and row(index). + + Args: + column_name (str): The name of the column containing the cell. + row_index (int): The index of the row containing the cell. + + Returns: + str: The text value of the cell. + """ + column_index = self.get_column_index(column_name) + if column_index == -1: + raise ValueError(f"Column '{column_name}' not found in table") + + # Locate all elements in the specified row and column + cell_locator = ( + f"{self.table_id} tbody tr:nth-child({row_index}) td:nth-child({column_index})" + ) + + cell = self.page.locator(cell_locator).first + + if cell: + return cell.inner_text() + else: + raise ValueError( + f"No cell found at column '{column_name}' and row index {row_index}" + ) + + def assert_surname_in_table(self, surname_pattern: str) -> None: + """ + Asserts that a surname matching the given pattern exists in the table. + Args: + surname_pattern (str): The surname or pattern to search for (supports '*' as a wildcard at the end). + """ + # Locate all surname cells (adjust selector as needed) + surname_criteria = self.page.locator( + "//table//tr[position()>1]/td[3]" + ) # Use the correct column index + if surname_pattern.endswith("*"): + prefix = surname_pattern[:-1] + found = any( + cell.inner_text().startswith(prefix) + for cell in surname_criteria.element_handles() + ) + else: + found = any( + surname_pattern == cell.inner_text() + for cell in surname_criteria.element_handles() + ) + assert found, f"No surname matching '{surname_pattern}' found in table." diff --git a/utils/user_tools.py b/utils/user_tools.py index f8026717..a5336ddd 100644 --- a/utils/user_tools.py +++ b/utils/user_tools.py @@ -1,8 +1,11 @@ import json import os import logging +import os from pathlib import Path - +from dotenv import load_dotenv +from playwright.sync_api import Page +from pages.login.cognito_login_page import CognitoLoginPage logger = logging.getLogger(__name__) USERS_FILE = Path(os.getcwd()) / "users.json" @@ -13,6 +16,26 @@ class UserTools: A utility class for retrieving and doing common actions with users. """ + @staticmethod + def user_login(page: Page, username: str) -> None: + """ + Logs into the BCSS application as a specified user. + + Args: + page (playwright.sync_api.Page): The Playwright page object to interact with. + username (str): Enter a username that exists in the users.json file. + """ + logging.info(f"Logging in as {username}") + # Go to base url + page.goto("/") + # Retrieve username from users.json + user_details = UserTools.retrieve_user(username) + # Login to bcss using retrieved username and a password stored in the .env file + password = os.getenv("BCSS_PASS") + if password is None: + raise ValueError("Environment variable 'BCSS_PASS' is not set") + CognitoLoginPage(page).login_as_user(user_details["username"], password) + @staticmethod def retrieve_user(user: str) -> dict: """ @@ -24,7 +47,7 @@ def retrieve_user(user: str) -> dict: Returns: dict: A Python dictionary with the details of the user requested, if present. """ - with open(USERS_FILE, 'r') as file: + with open(USERS_FILE, "r") as file: user_data = json.loads(file.read()) if user not in user_data: