Skip to content

Commit 90565b3

Browse files
Merge branch 'main' into feature/BCSS-21765-repo-documentation
2 parents 5a1bfb0 + be5a32d commit 90565b3

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)