Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions admin/views/minor_trace_chemistry.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ class MinorTraceChemistryAdmin(OcotilloModelView):
# ========== Basic Configuration ==========

identity = "n-m-a_-minor-trace-chemistry"
name = "Minor Trace Chemistry"
label = "Minor Trace Chemistry"
name = "NMA Minor Trace Chemistry"
label = "NMA Minor Trace Chemistry"
icon = "fa fa-flask"
pk_attr = "global_id"
pk_type = uuid.UUID
Expand All @@ -53,33 +53,45 @@ def can_delete(self, request: Request) -> bool:
list_fields = [
"global_id",
HasOne("chemistry_sample_info", identity="n-m-a_-chemistry_-sample-info"),
"sample_pt_id",
"sample_point_id",
"analyte",
"sample_value",
"units",
"symbol",
"analysis_date",
"analyses_agency",
"wclab_id",
"object_id",
]

sortable_fields = [
"global_id",
"sample_pt_id",
"sample_point_id",
"analyte",
"sample_value",
"units",
"symbol",
"analysis_date",
"analyses_agency",
"wclab_id",
"object_id",
Comment thread
ksmuczynski marked this conversation as resolved.
]

fields_default_sort = [("analysis_date", True)]

searchable_fields = [
"global_id",
"sample_pt_id",
"sample_point_id",
"analyte",
"symbol",
"analysis_method",
"analysis_date",
"notes",
"analyses_agency",
"wclab_id",
]

page_size = 50
Expand All @@ -90,6 +102,8 @@ def can_delete(self, request: Request) -> bool:
fields = [
"global_id",
HasOne("chemistry_sample_info", identity="n-m-a_-chemistry_-sample-info"),
"sample_pt_id",
"sample_point_id",
"analyte",
"symbol",
"sample_value",
Expand All @@ -100,23 +114,29 @@ def can_delete(self, request: Request) -> bool:
"notes",
"volume",
"volume_unit",
"object_id",
"analyses_agency",
"wclab_id",
]

field_labels = {
"global_id": "GlobalID",
"chemistry_sample_info": "Chemistry Sample Info",
"sample_pt_id": "SamplePtID",
"sample_point_id": "SamplePointID",
"analyte": "Analyte",
"symbol": "Symbol",
"sample_value": "Sample Value",
"sample_value": "SampleValue",
"units": "Units",
"uncertainty": "Uncertainty",
"analysis_method": "Analysis Method",
"analysis_date": "Analysis Date",
"analysis_method": "AnalysisMethod",
"analysis_date": "AnalysisDate",
"notes": "Notes",
"volume": "Volume",
"volume_unit": "Volume Unit",
"analyses_agency": "Analyses Agency",
"volume_unit": "VolumeUnit",
"object_id": "OBJECTID",
"analyses_agency": "AnalysesAgency",
"wclab_id": "WCLab_ID",
}


Expand Down
134 changes: 134 additions & 0 deletions alembic/versions/3a9c1f5b7d2e_align_nma_minor_trace_columns.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""Align NMA_MinorTraceChemistry columns with legacy schema.

Revision ID: 3a9c1f5b7d2e
Revises: c1d2e3f4a5b6
Create Date: 2026-01-31 12:00:00.000000
"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect

# revision identifiers, used by Alembic.
revision: str = "3a9c1f5b7d2e"
down_revision: Union[str, Sequence[str], None] = "c1d2e3f4a5b6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def _column_names(inspector, table_name: str) -> set[str]:
return {col["name"] for col in inspector.get_columns(table_name)}


def upgrade() -> None:
"""Rename legacy columns and add missing fields."""
bind = op.get_bind()
inspector = inspect(bind)
if not inspector.has_table("NMA_MinorTraceChemistry"):
return

table_name = "NMA_MinorTraceChemistry"
columns = _column_names(inspector, table_name)

rename_map = {
"chemistry_sample_info_id": "SamplePtID",
"sample_point_id": "SamplePointID",
"analyte": "Analyte",
"sample_value": "SampleValue",
"units": "Units",
"symbol": "Symbol",
"analysis_method": "AnalysisMethod",
"analysis_date": "AnalysisDate",
"notes": "Notes",
"analyses_agency": "AnalysesAgency",
"uncertainty": "Uncertainty",
"volume": "Volume",
"volume_unit": "VolumeUnit",
}

for old_name, new_name in rename_map.items():
if old_name in columns and new_name not in columns:
op.alter_column(table_name, old_name, new_column_name=new_name)
columns.remove(old_name)
columns.add(new_name)

if "SamplePointID" not in columns:
op.add_column(
table_name, sa.Column("SamplePointID", sa.String(length=10), nullable=True)
)
if "OBJECTID" not in columns:
op.add_column(table_name, sa.Column("OBJECTID", sa.Integer(), nullable=True))
if "WCLab_ID" not in columns:
op.add_column(
table_name, sa.Column("WCLab_ID", sa.String(length=25), nullable=True)
)

unique_constraints = inspector.get_unique_constraints(table_name)
unique_columns = {tuple(uc.get("column_names") or []) for uc in unique_constraints}
unique_names = {uc.get("name") for uc in unique_constraints}

if (
("OBJECTID",) not in unique_columns
and "uq_nma_minor_trace_chemistry_objectid" not in unique_names
):
op.create_unique_constraint(
"uq_nma_minor_trace_chemistry_objectid",
table_name,
["OBJECTID"],
)

if "uq_minor_trace_chemistry_sample_analyte" not in unique_names:
op.create_unique_constraint(
"uq_minor_trace_chemistry_sample_analyte",
table_name,
["SamplePtID", "Analyte"],
)


def downgrade() -> None:
"""Revert column names and remove added fields."""
bind = op.get_bind()
inspector = inspect(bind)
if not inspector.has_table("NMA_MinorTraceChemistry"):
return

table_name = "NMA_MinorTraceChemistry"
columns = _column_names(inspector, table_name)

unique_constraints = inspector.get_unique_constraints(table_name)
unique_names = {uc.get("name") for uc in unique_constraints}

if "uq_nma_minor_trace_chemistry_objectid" in unique_names:
op.drop_constraint(
"uq_nma_minor_trace_chemistry_objectid",
table_name,
type_="unique",
)

for column_name in ("WCLab_ID", "OBJECTID", "SamplePointID"):
if column_name in columns:
op.drop_column(table_name, column_name)

rename_map = {
"SamplePtID": "chemistry_sample_info_id",
"Analyte": "analyte",
"SampleValue": "sample_value",
"Units": "units",
"Symbol": "symbol",
"AnalysisMethod": "analysis_method",
"AnalysisDate": "analysis_date",
"Notes": "notes",
"AnalysesAgency": "analyses_agency",
"Uncertainty": "uncertainty",
"Volume": "volume",
"VolumeUnit": "volume_unit",
}

columns = _column_names(inspector, table_name)
for old_name, new_name in rename_map.items():
if old_name in columns and new_name not in columns:
op.alter_column(table_name, old_name, new_column_name=new_name)
columns.remove(old_name)
columns.add(new_name)
42 changes: 26 additions & 16 deletions db/nma_legacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,8 +475,8 @@ class NMA_MinorTraceChemistry(Base):
__tablename__ = "NMA_MinorTraceChemistry"
__table_args__ = (
UniqueConstraint(
"chemistry_sample_info_id",
"analyte",
"SamplePtID",
"Analyte",
name="uq_minor_trace_chemistry_sample_analyte",
),
)
Expand All @@ -486,32 +486,42 @@ class NMA_MinorTraceChemistry(Base):
)

# FK to ChemistrySampleInfo - required (no orphans)
chemistry_sample_info_id: Mapped[uuid.UUID] = mapped_column(
sample_pt_id: Mapped[uuid.UUID] = mapped_column(
"SamplePtID",
UUID(as_uuid=True),
ForeignKey("NMA_Chemistry_SampleInfo.SamplePtID", ondelete="CASCADE"),
nullable=False,
)

# Legacy columns
analyte: Mapped[Optional[str]] = mapped_column(String(50))
sample_value: Mapped[Optional[float]] = mapped_column(Float)
units: Mapped[Optional[str]] = mapped_column(String(20))
symbol: Mapped[Optional[str]] = mapped_column(String(10))
analysis_method: Mapped[Optional[str]] = mapped_column(String(100))
analysis_date: Mapped[Optional[date]] = mapped_column(Date)
notes: Mapped[Optional[str]] = mapped_column(Text)
analyses_agency: Mapped[Optional[str]] = mapped_column(String(100))
uncertainty: Mapped[Optional[float]] = mapped_column(Float)
volume: Mapped[Optional[int]] = mapped_column(Integer)
volume_unit: Mapped[Optional[str]] = mapped_column(String(20))
sample_point_id: Mapped[Optional[str]] = mapped_column("SamplePointID", String(10))
analyte: Mapped[Optional[str]] = mapped_column("Analyte", String(50))
symbol: Mapped[Optional[str]] = mapped_column("Symbol", String(50))
sample_value: Mapped[Optional[float]] = mapped_column(
"SampleValue", Float, server_default=text("0")
)
units: Mapped[Optional[str]] = mapped_column("Units", String(50))
uncertainty: Mapped[Optional[float]] = mapped_column("Uncertainty", Float)
analysis_method: Mapped[Optional[str]] = mapped_column(
"AnalysisMethod", String(255)
)
analysis_date: Mapped[Optional[datetime]] = mapped_column("AnalysisDate", DateTime)
Comment thread
ksmuczynski marked this conversation as resolved.
Comment thread
ksmuczynski marked this conversation as resolved.
notes: Mapped[Optional[str]] = mapped_column("Notes", String(255))
volume: Mapped[Optional[int]] = mapped_column(
"Volume", Integer, server_default=text("0")
)
volume_unit: Mapped[Optional[str]] = mapped_column("VolumeUnit", String(50))
object_id: Mapped[Optional[int]] = mapped_column("OBJECTID", Integer, unique=True)
analyses_agency: Mapped[Optional[str]] = mapped_column("AnalysesAgency", String(50))
wclab_id: Mapped[Optional[str]] = mapped_column("WCLab_ID", String(25))

# --- Relationships ---
chemistry_sample_info: Mapped["NMA_Chemistry_SampleInfo"] = relationship(
"NMA_Chemistry_SampleInfo", back_populates="minor_trace_chemistries"
)

@validates("chemistry_sample_info_id")
def validate_chemistry_sample_info_id(self, key, value):
@validates("sample_pt_id")
def validate_sample_pt_id(self, key, value):
"""Prevent orphan NMA_MinorTraceChemistry - must have a parent ChemistrySampleInfo."""
if value is None:
raise ValueError(
Expand Down
2 changes: 1 addition & 1 deletion tests/features/admin-minor-trace-chemistry.feature
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Feature: Minor Trace Chemistry Admin View
Then the Minor Trace Chemistry admin view should have these fields configured:
| field |
| global_id |
| chemistry_sample_info_id |
| sample_pt_id |
| analyte |
| symbol |
| sample_value |
Expand Down
4 changes: 2 additions & 2 deletions tests/integration/test_admin_minor_trace_chemistry.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def minor_trace_chemistry_record():
# Create MinorTraceChemistry record
chemistry = NMA_MinorTraceChemistry(
global_id=uuid.uuid4(),
chemistry_sample_info_id=sample_info.sample_pt_id,
sample_pt_id=sample_info.sample_pt_id,
analyte="Arsenic",
symbol="As",
sample_value=0.005,
Expand Down Expand Up @@ -120,7 +120,7 @@ def test_list_view_contains_view_name(self, admin_client):
"""List view should contain the view name."""
response = admin_client.get(f"{ADMIN_BASE_URL}/list")
assert response.status_code == 200
assert "Minor Trace Chemistry" in response.text
assert "NMA Minor Trace Chemistry" in response.text

def test_no_create_button_in_list_view(self, admin_client):
"""List view should not have a Create button for read-only view."""
Expand Down
16 changes: 11 additions & 5 deletions tests/test_admin_minor_trace_chemistry.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,15 @@ def test_minor_trace_chemistry_view_is_registered(self):
admin = create_admin(app)
view_names = [v.name for v in admin._views]

assert "Minor Trace Chemistry" in view_names, (
f"Expected 'Minor Trace Chemistry' to be registered in admin views. "
assert "NMA Minor Trace Chemistry" in view_names, (
f"Expected 'NMA Minor Trace Chemistry' to be registered in admin views. "
f"Found: {view_names}"
)

def test_view_has_correct_label(self):
"""View should have proper label for sidebar display."""
view = MinorTraceChemistryAdmin(NMA_MinorTraceChemistry)
assert view.label == "Minor Trace Chemistry"
assert view.label == "NMA Minor Trace Chemistry"

def test_class_has_flask_icon_configured(self):
"""View class should have flask icon configured for chemistry data."""
Expand Down Expand Up @@ -108,6 +108,8 @@ def test_list_fields_include_required_columns(self, view):
required_columns = [
"global_id",
"chemistry_sample_info", # HasOne relationship to parent
"sample_pt_id",
"sample_point_id",
"analyte",
"sample_value",
"units",
Expand Down Expand Up @@ -146,6 +148,8 @@ def test_form_includes_all_chemistry_fields(self):
# Note: chemistry_sample_info is a HasOne field, not a string
expected_string_fields = [
"global_id",
"sample_pt_id",
"sample_point_id",
"analyte",
"symbol",
"sample_value",
Expand All @@ -156,7 +160,9 @@ def test_form_includes_all_chemistry_fields(self):
"notes",
"volume",
"volume_unit",
"object_id",
"analyses_agency",
"wclab_id",
]
configured_fields = MinorTraceChemistryAdmin.fields

Expand All @@ -176,8 +182,8 @@ def test_form_includes_all_chemistry_fields(self):
def test_field_labels_are_human_readable(self, view):
"""Field labels should be human-readable."""
assert view.field_labels.get("global_id") == "GlobalID"
assert view.field_labels.get("sample_value") == "Sample Value"
assert view.field_labels.get("analysis_date") == "Analysis Date"
assert view.field_labels.get("sample_value") == "SampleValue"
assert view.field_labels.get("analysis_date") == "AnalysisDate"

def test_searchable_fields_include_key_fields(self, view):
"""Searchable fields should include commonly searched columns."""
Expand Down
Loading
Loading