Skip to content

Commit be5a32d

Browse files
vidhya-chandra1Andyg79adrianoaru-nhs
authored
Feature/bcss 20606 new regression release seek further data (#129)
<!-- markdownlint-disable-next-line first-line-heading --> ## Description Selenium to Playwright - Regression Tests - Subject - Seek Further Data <!-- Describe your changes in detail. --> ## Context Move Lynch self-referred subject to seeking Further Data (uncertified death), then back. Below are the lists of files where code change is implemented 1) Tests File --> test_lynch_self_referral_seeking_further_data_flow.py 2) base_page.py 3) POM --> pages --> subject --> subject_lynch_page.py <!-- Why is this change required? What problem does it solve? --> ## Type of changes <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply. --> - [X] Refactoring (non-breaking change) - [X] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would change existing functionality) - [ ] Bug fix (non-breaking change which fixes an issue) ## Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply. --> - [X] I am familiar with the [contributing guidelines](https://github.com/nhs-england-tools/playwright-python-blueprint/blob/main/CONTRIBUTING.md) - [X] I have followed the code style of the project - [X] I have added tests to cover my changes (where appropriate) - [ ] I have updated the documentation accordingly - [ ] This PR is a result of pair or mob programming --- ## Sensitive Information Declaration To ensure the utmost confidentiality and protect your and others privacy, we kindly ask you to NOT including [PII (Personal Identifiable Information) / PID (Personal Identifiable Data)](https://digital.nhs.uk/data-and-information/keeping-data-safe-and-benefitting-the-public) or any other sensitive data in this PR (Pull Request) and the codebase changes. We will remove any PR that do contain any sensitive information. We really appreciate your cooperation in this matter. - [X] I confirm that neither PII/PID nor sensitive data are included in this PR and the codebase changes. --------- Co-authored-by: AndyG <[email protected]> Co-authored-by: Adriano Aru <[email protected]>
1 parent e95889d commit be5a32d

File tree

10 files changed

+670
-18
lines changed

10 files changed

+670
-18
lines changed

classes/date/date_description_utils.py

Lines changed: 124 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from datetime import datetime, timedelta, date
2+
from dateutil.relativedelta import relativedelta
23
from typing import Optional
34
import logging
45
from classes.date.date_description import DateDescription
@@ -235,12 +236,129 @@ def is_valid_date(date_str: str, date_format: str) -> bool:
235236
@staticmethod
236237
def oracle_to_date_function(date_str: str, oracle_format: str) -> str:
237238
"""
238-
Constructs an Oracle TO_DATE function to convert a string to a date.
239-
This method formats the date string according to the specified format.
239+
Constructs a safe Oracle TO_DATE() expression from a Python date string.
240240
Args:
241-
date_str (str): The date string to be converted.
242-
oracle_format (str): The format in which the date string is provided.
241+
date_str (str): The date string to be converted (e.g., '06/10/1950').
242+
oracle_format (str): The Oracle format mask (e.g., 'dd/mm/yyyy').
243243
Returns:
244-
str: The SQL expression for the Oracle TO_DATE function.
244+
str: The SQL-safe TO_DATE(...) expression, or 'NULL' if date_str is None/empty.
245245
"""
246-
return f" TO_DATE( '{date_str}', '{oracle_format}') "
246+
if not date_str or str(date_str).strip().lower() == "none":
247+
return "NULL"
248+
249+
clean = str(date_str).strip().replace("\u00a0", "")
250+
py_fmt = (
251+
oracle_format.lower()
252+
.replace("dd", "%d")
253+
.replace("mm", "%m")
254+
.replace("yyyy", "%Y")
255+
)
256+
257+
# Validate in Python before sending to Oracle
258+
datetime.strptime(clean, py_fmt)
259+
260+
return f"TO_DATE('{clean}', '{oracle_format}')"
261+
262+
@staticmethod
263+
def convert_description_to_python_date(
264+
which_date: str, date_description: str
265+
) -> Optional[date]:
266+
"""
267+
Converts a date description (e.g. '2 years ago', '15/08/2020', 'NULL')
268+
into a Python datetime.date object or None.
269+
Args:
270+
which_date (str): Label for logging.
271+
date_description (str): Human-readable or exact date description.
272+
Returns:
273+
Optional[date]: The corresponding date object, or None if not applicable.
274+
"""
275+
logging.debug(
276+
f"convert_description_to_python_date: {which_date}, {date_description}"
277+
)
278+
279+
if not date_description or date_description.strip().upper() in (
280+
DateDescriptionUtils.NULL_STRING,
281+
"NONE",
282+
):
283+
return None
284+
285+
date_description = date_description.strip()
286+
today = datetime.today().date()
287+
288+
# Try direct date formats
289+
abs_date = _parse_absolute_date(date_description)
290+
if abs_date:
291+
return abs_date
292+
293+
# Try relative phrases like "3 years ago"
294+
rel_date = _parse_relative_date(date_description, today)
295+
if rel_date:
296+
return rel_date
297+
298+
# Try enum-based descriptions
299+
enum_date = _parse_enum_date(date_description, today)
300+
if enum_date is not None:
301+
return enum_date
302+
303+
return None
304+
305+
306+
def _parse_absolute_date(date_description: str) -> Optional[date]:
307+
"""
308+
Tries to parse an absolute date from the description.
309+
Args:
310+
date_description (str): The date description to parse.
311+
Returns:
312+
Optional[date]: The parsed absolute date or None if parsing failed.
313+
"""
314+
for fmt in (
315+
DateDescriptionUtils.DATE_FORMAT_DD_MM_YYYY,
316+
DateDescriptionUtils.DATE_FORMAT_YYYY_MM_DD,
317+
):
318+
if DateDescriptionUtils.is_valid_date(date_description, fmt):
319+
return datetime.strptime(date_description, fmt).date()
320+
return None
321+
322+
323+
def _parse_relative_date(date_description: str, today: date) -> Optional[date]:
324+
"""
325+
Tries to parse a relative date from the description (e.g., "3 years ago").
326+
Args:
327+
date_description (str): The date description to parse.
328+
today (date): The reference date for relative calculations.
329+
Returns:
330+
Optional[date]: The parsed relative date or None if parsing failed.
331+
"""
332+
words = date_description.split(" ")
333+
if date_description.endswith(" ago") and len(words) == 3:
334+
try:
335+
number = int(words[0])
336+
unit = words[1]
337+
if "year" in unit:
338+
return today - relativedelta(years=number)
339+
if "month" in unit:
340+
return today - relativedelta(months=number)
341+
if "day" in unit:
342+
return today - timedelta(days=number)
343+
except Exception as e:
344+
logging.warning(f"Could not parse relative date '{date_description}': {e}")
345+
return None
346+
347+
348+
def _parse_enum_date(date_description: str, today: date) -> Optional[date]:
349+
"""
350+
Tries to parse a date from the DateDescription enum.
351+
Args:
352+
date_description (str): The date description to parse.
353+
today (date): The reference date for special cases.
354+
Returns:
355+
Optional[date]: The parsed enum date or None if parsing failed.
356+
"""
357+
enum_val = DateDescription.by_description_case_insensitive(date_description)
358+
if enum_val is not None:
359+
if enum_val.name == DateDescriptionUtils.NULL_STRING:
360+
return None
361+
if enum_val.name == DateDescriptionUtils.NOT_NULL_STRING_UNDERSCORE:
362+
return today
363+
return enum_val.suitable_date
364+
return None
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from enum import Enum
2+
from typing import Optional
3+
4+
5+
class GeneticConditionType(Enum):
6+
"""
7+
Enum representing genetic condition types with valid value ID, description, and lower age.
8+
Provides lookup by description and valid value ID.
9+
"""
10+
11+
EPCAM = (307070, "EPCAM", 25)
12+
MLH1 = (306443, "MLH1", 25)
13+
MSH2 = (306444, "MSH2", 25)
14+
MSH6 = (306445, "MSH6", 35)
15+
PMS2 = (306446, "PMS2", 35)
16+
17+
def __init__(self, valid_value_id: int, description: str, lower_age: int):
18+
self._valid_value_id = valid_value_id
19+
self._description = description
20+
self._lower_age = lower_age
21+
22+
@property
23+
def valid_value_id(self) -> int:
24+
"""Return the valid value ID."""
25+
return self._valid_value_id
26+
27+
@property
28+
def description(self) -> str:
29+
"""Return the description."""
30+
return self._description
31+
32+
@property
33+
def lower_age(self) -> int:
34+
"""Return the lower age."""
35+
return self._lower_age
36+
37+
@classmethod
38+
def by_description(cls, description: str) -> Optional["GeneticConditionType"]:
39+
"""Return the GeneticConditionType instance for the given description."""
40+
for member in cls:
41+
if member.description == description:
42+
return member
43+
return None
44+
45+
@classmethod
46+
def by_valid_value_id(cls, valid_value_id: int) -> Optional["GeneticConditionType"]:
47+
"""Return the GeneticConditionType instance for the given valid value ID."""
48+
for member in cls:
49+
if member.valid_value_id == valid_value_id:
50+
return member
51+
return None

classes/repositories/general_repository.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,15 @@ def run_database_transition(
5555
raise oracledb.DatabaseError(f"Error executing database transition: {e}")
5656
finally:
5757
self.oracle_db.disconnect_from_db(conn)
58+
59+
def process_new_lynch_patients(self):
60+
logging.debug("[START] process_new_lynch_patients")
61+
try:
62+
self.oracle_db.execute_stored_procedure("pkg_lynch.p_process_patients")
63+
except Exception as e:
64+
logging.error("Error executing pkg_lynch.p_process_patients", exc_info=True)
65+
raise oracledb.DatabaseError(
66+
f"Error executing pkg_lynch.p_process_patients: {e}"
67+
)
68+
69+
logging.debug("[END] process_new_lynch_patients")

pages/screening_subject_search/subject_screening_summary_page.py

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import re
22
from playwright.sync_api import Page, expect, Locator
33
from pages.base_page import BasePage
4-
from enum import Enum
4+
from enum import StrEnum
55
import logging
66
import pytest
77
from typing import List
@@ -39,8 +39,10 @@ def __init__(self, page: Page):
3939
)
4040
self.patient_contacts = self.page.get_by_role("link", name="Patient Contacts")
4141
self.more = self.page.get_by_role("link", name="more")
42-
self.change_screening_status = self.page.get_by_label("Change Screening Status")
43-
self.reason = self.page.get_by_label("Reason", exact=True)
42+
self.change_screening_status_dropdown = self.page.get_by_label(
43+
"Change Screening Status"
44+
)
45+
self.reason_dropdown = self.page.get_by_label("Reason", exact=True)
4446
self.update_subject_data = self.page.get_by_role(
4547
"button", name="Update Subject Data"
4648
)
@@ -89,6 +91,9 @@ def __init__(self, page: Page):
8991
self.reopen_for_correction_button = self.page.get_by_role(
9092
"button", name="Reopen episode for correction"
9193
)
94+
self.self_refer_lynch_surveillance_button = self.page.get_by_role(
95+
"button", name="Self-refer Lynch Surveillance"
96+
)
9297

9398
def wait_for_page_title(self) -> None:
9499
"""Waits for the page to be the Subject Screening Summary"""
@@ -188,7 +193,7 @@ def click_more(self) -> None:
188193

189194
def click_update_subject_data(self) -> None:
190195
"""Click on the 'Update Subject Data' button."""
191-
self.click(self.update_subject_data)
196+
self.safe_accept_dialog(self.update_subject_data)
192197

193198
def click_close_fobt_screening_episode(self) -> None:
194199
"""Click on the 'Close FOBT Screening Episode' button."""
@@ -204,11 +209,11 @@ def go_to_a_page_to_close_the_episode(self) -> None:
204209

205210
def select_change_screening_status(self, option: str) -> None:
206211
"""Select the given 'change screening status' option."""
207-
self.change_screening_status.select_option(option)
212+
self.change_screening_status_dropdown.select_option(option)
208213

209214
def select_reason(self, option: str) -> None:
210215
"""Select the given 'reason' option."""
211-
self.reason.select_option(option)
216+
self.reason_dropdown.select_option(option)
212217

213218
def expand_episodes_list(self) -> None:
214219
"""Click on the episodes list expander icon."""
@@ -422,14 +427,35 @@ def reopen_fobt_screening_episode(self) -> None:
422427
# Step 2: Safely accept the confirmation dialog triggered by the correction button
423428
self.safe_accept_dialog(self.reopen_for_correction_button)
424429

430+
def click_self_refer_lynch_surveillance_button(self) -> None:
431+
"""Click on the 'Self-refer Lynch Surveillance' button"""
432+
self.safe_accept_dialog(self.self_refer_lynch_surveillance_button)
433+
self.page.wait_for_timeout(1000)
434+
435+
def change_screening_status(self, status_option: str, reason_option: str) -> None:
436+
"""
437+
Change the screening status of the subject by selecting the given status and reason options,
438+
then clicking the 'Update Subject Data' button.
439+
440+
Args:
441+
status_option (str): The option value to select for the screening status.
442+
reason_option (str): The option value to select for the reason.
443+
"""
444+
self.select_change_screening_status(status_option)
445+
self.select_reason(reason_option)
446+
self.click_update_subject_data()
447+
self.page.wait_for_timeout(1000)
448+
425449

426-
class ChangeScreeningStatusOptions(Enum):
450+
class ChangeScreeningStatusOptions(StrEnum):
427451
"""Enum for Change Screening Status options."""
428452

429453
SEEKING_FURTHER_DATA = "4007"
454+
LYNCH_SELF_REFERRAL = "307129"
430455

431456

432-
class ReasonOptions(Enum):
457+
class ReasonOptions(StrEnum):
433458
"""Enum for Reason options."""
434459

435460
UNCERTIFIED_DEATH = "11314"
461+
RESET_SEEKING_FURTHER_DATA_TO_LYNCH_SELF_REFERRAL = "307135"

pytest.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,4 @@ markers =
5555
spine_retrieval_search_tests: tests that are part of subject spine retrieval demographics
5656
hub_user_tests: tests that are part of the hub user test suite
5757
fobt_regression_tests: tests that are part of the fobt regression test suite
58+
lynch_self_referral_tests: tests that are part of the lynch self referral test suite

0 commit comments

Comments
 (0)