diff --git a/.importlinter b/.importlinter index d710a37..a21f028 100644 --- a/.importlinter +++ b/.importlinter @@ -3,12 +3,13 @@ root_package = pbs_client include_external_packages = True [importlinter:contract:pbs-client-layers] -name = pbs-client layers: sync sits on http+db, query sits on db only +name = pbs-client layers: toolkit sits on db, sync sits on http+db type = layers layers = pbs_client.cli pbs_client.sync - pbs_client.query + pbs_client.toolkit.integrations + pbs_client.toolkit.analytics + pbs_client.toolkit.core pbs_client.db pbs_client.http - diff --git a/src/pbs_client/cli/__init__.py b/src/pbs_client/cli/__init__.py index 0cc050c..3212662 100644 --- a/src/pbs_client/cli/__init__.py +++ b/src/pbs_client/cli/__init__.py @@ -3,4 +3,3 @@ from pbs_client.cli.main import app, main __all__ = ["app", "main"] - diff --git a/src/pbs_client/http/__init__.py b/src/pbs_client/http/__init__.py index 34db9ea..ac44ed5 100644 --- a/src/pbs_client/http/__init__.py +++ b/src/pbs_client/http/__init__.py @@ -3,4 +3,3 @@ from pbs_client.http.client import GlobalRateLimiter, Page, PBSClient, TransportResponse __all__ = ["GlobalRateLimiter", "PBSClient", "Page", "TransportResponse"] - diff --git a/src/pbs_client/query/service.py b/src/pbs_client/query/service.py deleted file mode 100644 index a281160..0000000 --- a/src/pbs_client/query/service.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Read-only, local-only convenience queries over the PBS mirror.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from datetime import date, datetime -from typing import Any - -from sqlalchemy import or_, select -from sqlalchemy.orm import Session - -from pbs_client.db.model import ( - ATC, - Indication, - Item, - ItemAtcRltd, - ItemRestrictionRltd, - PrescribingTxt, - RestrictionText, - RstrctnPrscrbngTxtRltd, - Schedule, -) - - -@dataclass(frozen=True, slots=True) -class RestrictionExpansion: - restriction: RestrictionText - prescribing_texts: list[PrescribingTxt] = field(default_factory=list) - indications: list[Indication] = field(default_factory=list) - - -@dataclass(frozen=True, slots=True) -class ItemExpansion: - item: Item - restrictions: list[RestrictionExpansion] = field(default_factory=list) - atc_codes: list[ATC] = field(default_factory=list) - - -def _as_date(value: str | date | datetime) -> date: - if isinstance(value, datetime): - return value.date() - if isinstance(value, date): - return value - text = str(value) - for parser in (date.fromisoformat,): - try: - return parser(text) - except ValueError: - pass - for fmt in ("%d/%m/%Y", "%Y%m%d", "%d-%m-%Y"): - try: - return datetime.strptime(text, fmt).date() # noqa: DTZ007 - date-only formats - except ValueError: - pass - raise ValueError(f"cannot parse PBS date: {value!r}") - - -def resolve_schedule(session: Session, as_of: date | datetime | str) -> Schedule | None: - """Resolve the latest schedule effective on ``as_of`` by date.""" - - target = _as_date(as_of) - schedules = session.scalars(select(Schedule)).all() - eligible = [schedule for schedule in schedules if _as_date(schedule.effective_date) <= target] - return max(eligible, key=lambda schedule: _as_date(schedule.effective_date), default=None) - - -def find_items( - session: Session, - item_code: str, - *, - schedule_code: float | None = None, - as_of: date | datetime | str | None = None, -) -> list[Item]: - """Find PBS items by ``pbs_code`` and optionally by schedule/date.""" - - if as_of is not None and schedule_code is None: - schedule = resolve_schedule(session, as_of) - schedule_code = schedule.schedule_code if schedule else None - statement = select(Item).where(or_(Item.pbs_code == item_code, Item.li_item_id == item_code)) - if schedule_code is not None: - statement = statement.where(Item.schedule_code == schedule_code) - return list(session.scalars(statement).all()) - - -def get_item(session: Session, item_code: str, **kwargs: Any) -> Item | None: - """Return the first matching item, or ``None``.""" - - return next(iter(find_items(session, item_code, **kwargs)), None) - - -def get_item_restrictions(session: Session, item: Item) -> list[RestrictionExpansion]: - """Expand an item through restrictions, prescribing text, and indications.""" - - if item.pbs_code is None: - return [] - links = session.scalars( - select(ItemRestrictionRltd).where( - ItemRestrictionRltd.schedule_code == item.schedule_code, - ItemRestrictionRltd.pbs_code == item.pbs_code, - ) - ).all() - expansions: list[RestrictionExpansion] = [] - for link in links: - restriction = session.get(RestrictionText, (item.schedule_code, link.res_code)) - if restriction is None: - continue - text_links = session.scalars( - select(RstrctnPrscrbngTxtRltd).where( - RstrctnPrscrbngTxtRltd.schedule_code == item.schedule_code, - RstrctnPrscrbngTxtRltd.res_code == link.res_code, - ) - ).all() - texts = [ - text - for text_link in text_links - if (text := session.get( - PrescribingTxt, - (item.schedule_code, text_link.prescribing_text_id), - )) - is not None - ] - indications: list[Indication] = [] - for text in texts: - indications.extend( - session.scalars( - select(Indication).where( - Indication.schedule_code == item.schedule_code, - Indication.indication_prescribing_txt_id == text.prescribing_txt_id, - ) - ).all() - ) - expansions.append(RestrictionExpansion(restriction, texts, indications)) - return expansions - - -def get_item_atc_codes(session: Session, item: Item) -> list[ATC]: - """Expand an item through its ATC relationship rows.""" - - if item.pbs_code is None: - return [] - links = session.scalars( - select(ItemAtcRltd).where( - ItemAtcRltd.schedule_code == item.schedule_code, - ItemAtcRltd.pbs_code == item.pbs_code, - ) - ).all() - return [ - atc - for link in links - if (atc := session.get(ATC, (item.schedule_code, link.atc_code))) is not None - ] - - -def expand_item(session: Session, item: Item) -> ItemExpansion: - """Return the complete v1 convenience expansion for an item.""" - - return ItemExpansion( - item=item, - restrictions=get_item_restrictions(session, item), - atc_codes=get_item_atc_codes(session, item), - ) - - -# Friendly aliases for downstream callers that prefer verb-based names. -item_restrictions = get_item_restrictions -item_atc_codes = get_item_atc_codes -lookup_item = get_item diff --git a/src/pbs_client/sync/__init__.py b/src/pbs_client/sync/__init__.py index 53afe97..2215bcd 100644 --- a/src/pbs_client/sync/__init__.py +++ b/src/pbs_client/sync/__init__.py @@ -3,4 +3,3 @@ from pbs_client.sync.orchestrator import SyncOrchestrator, SyncResult, mirror_status, upsert_records __all__ = ["SyncOrchestrator", "SyncResult", "mirror_status", "upsert_records"] - diff --git a/src/pbs_client/toolkit/__init__.py b/src/pbs_client/toolkit/__init__.py new file mode 100644 index 0000000..c8f4747 --- /dev/null +++ b/src/pbs_client/toolkit/__init__.py @@ -0,0 +1 @@ +"""PBS-native navigation and indication-candidate helpers.""" diff --git a/src/pbs_client/toolkit/analytics/__init__.py b/src/pbs_client/toolkit/analytics/__init__.py new file mode 100644 index 0000000..73c798f --- /dev/null +++ b/src/pbs_client/toolkit/analytics/__init__.py @@ -0,0 +1,5 @@ +"""PBS-native composed analytics over toolkit core primitives.""" + +from pbs_client.toolkit.analytics.indications import IndicationCandidate, indication_candidates + +__all__ = ["IndicationCandidate", "indication_candidates"] diff --git a/src/pbs_client/toolkit/analytics/indications.py b/src/pbs_client/toolkit/analytics/indications.py new file mode 100644 index 0000000..88d01e2 --- /dev/null +++ b/src/pbs_client/toolkit/analytics/indications.py @@ -0,0 +1,49 @@ +"""Indication-candidate composition for PBS items.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime + +from sqlalchemy.orm import Session + +from pbs_client.db.model import Item, Schedule +from pbs_client.toolkit.core import IndicationText, find_items, get_item_indication_text + + +@dataclass(frozen=True, slots=True) +class IndicationCandidate: + """A PBS item, its schedule, and one traceable indication value.""" + + item: Item + schedule: Schedule + indication: IndicationText + + +def indication_candidates( + session: Session, + item_code: str, + *, + as_of: date | datetime | str | None = None, +) -> list[IndicationCandidate]: + """Resolve an item code to all structured or fallback indications. + + With ``as_of``, item lookup is restricted to the schedule effective on + that date. Without it, every matching historical schedule remains + distinct. The local mirror's schedule row is paired using the item's + opaque ``schedule_code`` rather than schedule-code magnitude. + """ + + candidates: list[IndicationCandidate] = [] + for item in find_items(session, item_code, as_of=as_of): + schedule = session.get(Schedule, item.schedule_code) + if schedule is None: + continue + candidates.extend( + IndicationCandidate(item=item, schedule=schedule, indication=indication) + for indication in get_item_indication_text(session, item) + ) + return candidates + + +__all__ = ["IndicationCandidate", "indication_candidates"] diff --git a/src/pbs_client/query/__init__.py b/src/pbs_client/toolkit/core/__init__.py similarity index 65% rename from src/pbs_client/query/__init__.py rename to src/pbs_client/toolkit/core/__init__.py index 33a3b25..ec67a7c 100644 --- a/src/pbs_client/query/__init__.py +++ b/src/pbs_client/toolkit/core/__init__.py @@ -1,12 +1,15 @@ -"""Offline query helpers for the local PBS mirror.""" +"""Foundational, local-only navigation over the PBS mirror.""" -from pbs_client.query.service import ( +from pbs_client.toolkit.core.service import ( + BenefitTypeCode, + IndicationText, ItemExpansion, RestrictionExpansion, expand_item, find_items, get_item, get_item_atc_codes, + get_item_indication_text, get_item_restrictions, item_atc_codes, item_restrictions, @@ -15,16 +18,18 @@ ) __all__ = [ + "BenefitTypeCode", + "IndicationText", "ItemExpansion", "RestrictionExpansion", "expand_item", "find_items", "get_item", "get_item_atc_codes", + "get_item_indication_text", "get_item_restrictions", "item_atc_codes", "item_restrictions", "lookup_item", "resolve_schedule", ] - diff --git a/src/pbs_client/toolkit/core/service.py b/src/pbs_client/toolkit/core/service.py new file mode 100644 index 0000000..0a8a36e --- /dev/null +++ b/src/pbs_client/toolkit/core/service.py @@ -0,0 +1,330 @@ +"""Composable local-only navigation over PBS mirror models.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date, datetime +from enum import Enum +from html.parser import HTMLParser +from typing import Any, Literal + +from sqlalchemy import or_, select +from sqlalchemy.orm import Session + +from pbs_client.db.model import ( + ATC, + Indication, + Item, + ItemAtcRltd, + ItemRestrictionRltd, + PrescribingTxt, + RestrictionText, + RstrctnPrscrbngTxtRltd, + Schedule, +) + + +class BenefitTypeCode(str, Enum): + """PBS restriction category recorded on a prescribing rule.""" + + UNRESTRICTED = "U" + RESTRICTED = "R" + AUTHORITY_REQUIRED = "A" + STREAMLINED = "S" + + +@dataclass(frozen=True, slots=True) +class IndicationText: + """One traceable structured or fallback indication text value.""" + + text: str + source: Literal["indication", "restriction_text"] + schedule_code: int + res_code: str + prescribing_txt_id: int | None + benefit_type_code: BenefitTypeCode + episodicity: str | None = None + severity: str | None = None + + +@dataclass(frozen=True, slots=True) +class RestrictionExpansion: + restriction: RestrictionText + prescribing_texts: list[PrescribingTxt] = field(default_factory=list) + indications: list[Indication] = field(default_factory=list) + + +@dataclass(frozen=True, slots=True) +class ItemExpansion: + item: Item + restrictions: list[RestrictionExpansion] = field(default_factory=list) + atc_codes: list[ATC] = field(default_factory=list) + + +class _HTMLTextExtractor(HTMLParser): + """Extract readable text while keeping boundaries between HTML blocks.""" + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.parts: list[str] = [] + + def handle_data(self, data: str) -> None: + self.parts.append(data) + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag in {"br", "div", "li", "p", "tr"}: + self.parts.append(" ") + + +def _clean_html(value: str | None) -> str | None: + if not value: + return None + parser = _HTMLTextExtractor() + parser.feed(value) + text = " ".join("".join(parser.parts).split()) + return text or None + + +def _as_date(value: str | date | datetime) -> date: + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + text = str(value) + try: + return date.fromisoformat(text) + except ValueError: + pass + for fmt in ("%d/%m/%Y", "%Y%m%d", "%d-%m-%Y"): + try: + return datetime.strptime(text, fmt).date() # noqa: DTZ007 - date-only formats + except ValueError: + pass + raise ValueError(f"cannot parse PBS date: {value!r}") + + +def resolve_schedule(session: Session, as_of: date | datetime | str) -> Schedule | None: + """Resolve the latest schedule effective on ``as_of`` by date.""" + + target = _as_date(as_of) + schedules = session.scalars(select(Schedule)).all() + eligible = [schedule for schedule in schedules if _as_date(schedule.effective_date) <= target] + return max(eligible, key=lambda schedule: _as_date(schedule.effective_date), default=None) + + +def find_items( + session: Session, + item_code: str, + *, + schedule_code: int | None = None, + as_of: date | datetime | str | None = None, +) -> list[Item]: + """Find PBS items by code and optionally by schedule/date.""" + + if as_of is not None and schedule_code is None: + schedule = resolve_schedule(session, as_of) + if schedule is None: + return [] + schedule_code = schedule.schedule_code + statement = select(Item).where(or_(Item.pbs_code == item_code, Item.li_item_id == item_code)) + if schedule_code is not None: + statement = statement.where(Item.schedule_code == schedule_code) + return list(session.scalars(statement).all()) + + +def get_item(session: Session, item_code: str, **kwargs: Any) -> Item | None: + """Return the first matching item, or ``None``.""" + + return next(iter(find_items(session, item_code, **kwargs)), None) + + +def get_item_restrictions(session: Session, item: Item) -> list[RestrictionExpansion]: + """Expand an item through restrictions, prescribing text, and indications.""" + + if item.pbs_code is None: + return [] + links = session.scalars( + select(ItemRestrictionRltd).where( + ItemRestrictionRltd.schedule_code == item.schedule_code, + ItemRestrictionRltd.pbs_code == item.pbs_code, + ) + ).all() + expansions: list[RestrictionExpansion] = [] + for link in links: + restriction = session.get(RestrictionText, (item.schedule_code, link.res_code)) + if restriction is None: + continue + text_links = session.scalars( + select(RstrctnPrscrbngTxtRltd).where( + RstrctnPrscrbngTxtRltd.schedule_code == item.schedule_code, + RstrctnPrscrbngTxtRltd.res_code == link.res_code, + ) + ).all() + texts = [ + text + for text_link in text_links + if ( + text := session.get( + PrescribingTxt, + (item.schedule_code, text_link.prescribing_text_id), + ) + ) + is not None + ] + indications: list[Indication] = [] + for text in texts: + indications.extend( + session.scalars( + select(Indication).where( + Indication.schedule_code == item.schedule_code, + Indication.indication_prescribing_txt_id == text.prescribing_txt_id, + ) + ).all() + ) + expansions.append(RestrictionExpansion(restriction, texts, indications)) + return expansions + + +def get_item_indication_text(session: Session, item: Item) -> list[IndicationText]: + """Return structured indications, or explicitly-provenanced text fallbacks. + + Notes and cautions are excluded using the PBS relationship's + ``restriction_indicator`` field. A restriction produces a fallback only + when it has no usable linked ``INDICATION`` condition. + """ + + if item.pbs_code is None: + return [] + links = session.scalars( + select(ItemRestrictionRltd).where( + ItemRestrictionRltd.schedule_code == item.schedule_code, + ItemRestrictionRltd.pbs_code == item.pbs_code, + ItemRestrictionRltd.restriction_indicator == "Y", + ) + ).all() + results: list[IndicationText] = [] + for link in links: + restriction = session.get(RestrictionText, (item.schedule_code, link.res_code)) + if restriction is None: + continue + benefit_type = BenefitTypeCode(link.benefit_type_code) + structured = _structured_indications(session, item, link, benefit_type) + if structured: + results.extend(structured) + continue + if fallback := _fallback_indication(item, link, restriction, benefit_type): + results.append(fallback) + return results + + +def _structured_indications( + session: Session, + item: Item, + link: ItemRestrictionRltd, + benefit_type: BenefitTypeCode, +) -> list[IndicationText]: + text_links = session.scalars( + select(RstrctnPrscrbngTxtRltd) + .where( + RstrctnPrscrbngTxtRltd.schedule_code == item.schedule_code, + RstrctnPrscrbngTxtRltd.res_code == link.res_code, + ) + .order_by(RstrctnPrscrbngTxtRltd.pt_position) + ).all() + results: list[IndicationText] = [] + for text_link in text_links: + prescribing_text = session.get( + PrescribingTxt, + (item.schedule_code, text_link.prescribing_text_id), + ) + if prescribing_text is None or prescribing_text.prescribing_type != "INDICATION": + continue + indication = session.get( + Indication, + (item.schedule_code, prescribing_text.prescribing_txt_id), + ) + if indication is None or not indication.condition or not indication.condition.strip(): + continue + results.append( + IndicationText( + text=indication.condition.strip(), + source="indication", + schedule_code=item.schedule_code, + res_code=link.res_code, + prescribing_txt_id=prescribing_text.prescribing_txt_id, + benefit_type_code=benefit_type, + episodicity=indication.episodicity, + severity=indication.severity, + ) + ) + return results + + +def _fallback_indication( + item: Item, + link: ItemRestrictionRltd, + restriction: RestrictionText, + benefit_type: BenefitTypeCode, +) -> IndicationText | None: + text = _clean_html(restriction.schedule_html_text) or _clean_html(restriction.li_html_text) + if not text: + return None + return IndicationText( + text=text, + source="restriction_text", + schedule_code=item.schedule_code, + res_code=link.res_code, + prescribing_txt_id=None, + benefit_type_code=benefit_type, + ) + + +def get_item_atc_codes(session: Session, item: Item) -> list[ATC]: + """Expand an item through its ATC relationship rows.""" + + if item.pbs_code is None: + return [] + links = session.scalars( + select(ItemAtcRltd).where( + ItemAtcRltd.schedule_code == item.schedule_code, + ItemAtcRltd.pbs_code == item.pbs_code, + ) + ).all() + return [ + atc + for link in links + if (atc := session.get(ATC, (item.schedule_code, link.atc_code))) is not None + ] + + +def expand_item(session: Session, item: Item) -> ItemExpansion: + """Return the complete convenience expansion for an item.""" + + return ItemExpansion( + item=item, + restrictions=get_item_restrictions(session, item), + atc_codes=get_item_atc_codes(session, item), + ) + + +item_restrictions = get_item_restrictions +item_atc_codes = get_item_atc_codes +lookup_item = get_item + + +__all__ = [ + "BenefitTypeCode", + "IndicationText", + "ItemExpansion", + "RestrictionExpansion", + "expand_item", + "find_items", + "get_item", + "get_item_atc_codes", + "get_item_indication_text", + "get_item_restrictions", + "item_atc_codes", + "item_restrictions", + "lookup_item", + "resolve_schedule", +] diff --git a/src/pbs_client/toolkit/integrations/__init__.py b/src/pbs_client/toolkit/integrations/__init__.py new file mode 100644 index 0000000..8ab6151 --- /dev/null +++ b/src/pbs_client/toolkit/integrations/__init__.py @@ -0,0 +1 @@ +"""Reserved for future optional integrations; no OMOP code lives here yet.""" diff --git a/tests/conftest.py b/tests/conftest.py index 23d29f6..d575db7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,4 +19,3 @@ def session_factory(): @pytest.fixture def fixture_dir() -> Path: return Path(__file__).parent / "fixtures" - diff --git a/tests/test_query.py b/tests/test_query.py index bbf2c03..1821283 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -11,21 +11,63 @@ RstrctnPrscrbngTxtRltd, Schedule, ) -from pbs_client.query import expand_item, find_items, resolve_schedule +from pbs_client.toolkit.analytics import indication_candidates +from pbs_client.toolkit.core import ( + BenefitTypeCode, + expand_item, + find_items, + get_item_indication_text, + resolve_schedule, +) def test_offline_item_restriction_and_atc_expansion(session_factory): with session_factory() as session: session.add_all( [ - Schedule(schedule_code=7, effective_date="2026-01-01", effective_year=2026, revision_number=1, start_tsp="2026-01-01T00:00:00Z", effective_month="January", publication_status="PUBLISHED"), + Schedule( + schedule_code=7, + effective_date="2026-01-01", + effective_year=2026, + revision_number=1, + start_tsp="2026-01-01T00:00:00Z", + effective_month="January", + publication_status="PUBLISHED", + ), Item(schedule_code=7, li_item_id="li-1", pbs_code="X1", drug_name="Drug"), ATC(schedule_code=7, atc_code="A01", atc_description="Example", atc_level=1), ItemAtcRltd(schedule_code=7, pbs_code="X1", atc_code="A01", atc_priority_pct=100), - RestrictionText(schedule_code=7, res_code="R1", schedule_html_text="Use for indication", note_indicator="N", caution_indicator="N", complex_authority_rqrd_ind="N", variation_rule_applied="N", first_listing_date="2026-01-01", written_authority_required="N"), - ItemRestrictionRltd(schedule_code=7, pbs_code="X1", res_code="R1", benefit_type_code="R", restriction_indicator="Y"), - PrescribingTxt(schedule_code=7, prescribing_txt_id=9, prescribing_type="INDICATION", prescribing_txt="Text", prscrbg_txt_html="
Text
", complex_authority_rqrd_ind="N", apply_to_increase_mq_flag="N", apply_to_increase_nr_flag="N"), - RstrctnPrscrbngTxtRltd(schedule_code=7, res_code="R1", prescribing_text_id=9, pt_position=1), + RestrictionText( + schedule_code=7, + res_code="R1", + schedule_html_text="Use for indication", + note_indicator="N", + caution_indicator="N", + complex_authority_rqrd_ind="N", + variation_rule_applied="N", + first_listing_date="2026-01-01", + written_authority_required="N", + ), + ItemRestrictionRltd( + schedule_code=7, + pbs_code="X1", + res_code="R1", + benefit_type_code="R", + restriction_indicator="Y", + ), + PrescribingTxt( + schedule_code=7, + prescribing_txt_id=9, + prescribing_type="INDICATION", + prescribing_txt="Text", + prscrbg_txt_html="Text
", + complex_authority_rqrd_ind="N", + apply_to_increase_mq_flag="N", + apply_to_increase_nr_flag="N", + ), + RstrctnPrscrbngTxtRltd( + schedule_code=7, res_code="R1", prescribing_text_id=9, pt_position=1 + ), Indication(schedule_code=7, indication_prescribing_txt_id=9, condition="Condition"), ] ) @@ -37,3 +79,105 @@ def test_offline_item_restriction_and_atc_expansion(session_factory): assert expanded.atc_codes[0].atc_code == "A01" assert expanded.restrictions[0].indications[0].condition == "Condition" + + +def test_indication_candidates_preserve_structured_provenance(session_factory): + with session_factory() as session: + session.add_all( + [ + Schedule(schedule_code=8, effective_date="2026-02-01", effective_year=2026), + Item(schedule_code=8, li_item_id="li-2", pbs_code="X2", drug_name="Drug"), + RestrictionText( + schedule_code=8, + res_code="R2", + schedule_html_text="Fallback should not be used
", + ), + ItemRestrictionRltd( + schedule_code=8, + pbs_code="X2", + res_code="R2", + benefit_type_code="A", + restriction_indicator="Y", + ), + PrescribingTxt( + schedule_code=8, + prescribing_txt_id=10, + prescribing_type="INDICATION", + prescribing_txt="Condition text", + ), + RstrctnPrscrbngTxtRltd( + schedule_code=8, + res_code="R2", + prescribing_text_id=10, + pt_position=1, + ), + Indication( + schedule_code=8, + indication_prescribing_txt_id=10, + condition="Condition text", + episodicity="Persistent", + severity="Severe", + ), + ] + ) + session.commit() + + candidates = indication_candidates(session, "X2", as_of="2026-02-01") + + assert len(candidates) == 1 + candidate = candidates[0] + assert candidate.indication.source == "indication" + assert candidate.indication.schedule_code == 8 + assert candidate.indication.prescribing_txt_id == 10 + assert candidate.indication.benefit_type_code is BenefitTypeCode.AUTHORITY_REQUIRED + assert candidate.indication.episodicity == "Persistent" + assert candidate.indication.severity == "Severe" + + +def test_indication_text_uses_clean_fallback_and_excludes_notes(session_factory): + with session_factory() as session: + session.add_all( + [ + Schedule(schedule_code=9, effective_date="2026-03-01", effective_year=2026), + Item(schedule_code=9, li_item_id="li-3", pbs_code="X3", drug_name="Drug"), + RestrictionText( + schedule_code=9, + res_code="R3", + schedule_html_text="Use only for & condition.
", + ), + RestrictionText( + schedule_code=9, + res_code="N3", + schedule_html_text="This is an administrative note.
", + ), + ItemRestrictionRltd( + schedule_code=9, + pbs_code="X3", + res_code="R3", + benefit_type_code="S", + restriction_indicator="Y", + ), + ItemRestrictionRltd( + schedule_code=9, + pbs_code="X3", + res_code="N3", + benefit_type_code="S", + restriction_indicator="N", + ), + ] + ) + session.commit() + indications = get_item_indication_text(session, session.get(Item, (9, "li-3"))) + + assert len(indications) == 1 + assert indications[0].source == "restriction_text" + assert indications[0].text == "Use only for & condition." + assert indications[0].prescribing_txt_id is None + + +def test_item_lookup_with_unknown_date_returns_no_items(session_factory): + with session_factory() as session: + session.add(Item(schedule_code=7, li_item_id="li-4", pbs_code="X4", drug_name="Drug")) + session.commit() + + assert find_items(session, "X4", as_of="2025-01-01") == []