Skip to content

Commit 393c553

Browse files
authored
Merge pull request #469 from DataIntegrationGroup/kas-bdms-540-NMA_SurfaceWaterPhotos-FK-relationship
BDMS-540: Link NMA_SurfaceWaterPhotos to NMA_SurfaceWaterData
2 parents 5f31f62 + 830609d commit 393c553

8 files changed

Lines changed: 374 additions & 38 deletions
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""link surface water photos to surface water data
2+
3+
Revision ID: a1b2c3d4e5f6
4+
Revises: f6e5d4c3b2a1
5+
Create Date: 2026-02-05 11:10:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = "a1b2c3d4e5f6"
16+
down_revision: Union[str, Sequence[str], None] = "f6e5d4c3b2a1"
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema."""
23+
op.create_unique_constraint(
24+
"uq_surface_water_data_surface_id",
25+
"NMA_SurfaceWaterData",
26+
["SurfaceID"],
27+
)
28+
op.create_foreign_key(
29+
"fk_surface_water_photos_surface_id",
30+
"NMA_SurfaceWaterPhotos",
31+
"NMA_SurfaceWaterData",
32+
["SurfaceID"],
33+
["SurfaceID"],
34+
ondelete="CASCADE",
35+
)
36+
op.execute("""
37+
DELETE FROM "NMA_SurfaceWaterPhotos" p
38+
WHERE p."SurfaceID" IS NULL
39+
OR NOT EXISTS (
40+
SELECT 1
41+
FROM "NMA_SurfaceWaterData" d
42+
WHERE d."SurfaceID" = p."SurfaceID"
43+
)
44+
""")
45+
op.alter_column(
46+
"NMA_SurfaceWaterPhotos",
47+
"SurfaceID",
48+
existing_type=sa.UUID(),
49+
nullable=False,
50+
)
51+
52+
53+
def downgrade() -> None:
54+
"""Downgrade schema."""
55+
op.alter_column(
56+
"NMA_SurfaceWaterPhotos",
57+
"SurfaceID",
58+
existing_type=sa.UUID(),
59+
nullable=True,
60+
)
61+
op.drop_constraint(
62+
"fk_surface_water_photos_surface_id",
63+
"NMA_SurfaceWaterPhotos",
64+
type_="foreignkey",
65+
)
66+
op.drop_constraint(
67+
"uq_surface_water_data_surface_id",
68+
"NMA_SurfaceWaterData",
69+
type_="unique",
70+
)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""link weather photos to weather data
2+
3+
Revision ID: b7c8d9e0f1a2
4+
Revises: a1b2c3d4e5f6
5+
Create Date: 2026-02-05 11:20:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = "b7c8d9e0f1a2"
16+
down_revision: Union[str, Sequence[str], None] = "a1b2c3d4e5f6"
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema."""
23+
op.create_unique_constraint(
24+
"uq_weather_data_weather_id",
25+
"NMA_WeatherData",
26+
["WeatherID"],
27+
)
28+
op.create_foreign_key(
29+
"fk_weather_photos_weather_id",
30+
"NMA_WeatherPhotos",
31+
"NMA_WeatherData",
32+
["WeatherID"],
33+
["WeatherID"],
34+
ondelete="CASCADE",
35+
)
36+
op.execute("""
37+
DELETE FROM "NMA_WeatherPhotos" p
38+
WHERE p."WeatherID" IS NULL
39+
OR NOT EXISTS (
40+
SELECT 1
41+
FROM "NMA_WeatherData" d
42+
WHERE d."WeatherID" = p."WeatherID"
43+
)
44+
""")
45+
op.alter_column(
46+
"NMA_WeatherPhotos",
47+
"WeatherID",
48+
existing_type=sa.UUID(),
49+
nullable=False,
50+
)
51+
52+
53+
def downgrade() -> None:
54+
"""Downgrade schema."""
55+
op.alter_column(
56+
"NMA_WeatherPhotos",
57+
"WeatherID",
58+
existing_type=sa.UUID(),
59+
nullable=True,
60+
)
61+
op.drop_constraint(
62+
"fk_weather_photos_weather_id",
63+
"NMA_WeatherPhotos",
64+
type_="foreignkey",
65+
)
66+
op.drop_constraint(
67+
"uq_weather_data_weather_id",
68+
"NMA_WeatherData",
69+
type_="unique",
70+
)

db/nma_legacy.py

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -586,7 +586,7 @@ class NMA_SurfaceWaterData(Base):
586586

587587
# Legacy PK (for audit)
588588
surface_id: Mapped[uuid.UUID] = mapped_column(
589-
"SurfaceID", UUID(as_uuid=True), nullable=False
589+
"SurfaceID", UUID(as_uuid=True), nullable=False, unique=True
590590
)
591591

592592
# Legacy FK (for audit)
@@ -617,6 +617,12 @@ class NMA_SurfaceWaterData(Base):
617617

618618
# Relationships
619619
thing: Mapped["Thing"] = relationship("Thing", back_populates="surface_water_data")
620+
surface_water_photos: Mapped[list["NMA_SurfaceWaterPhotos"]] = relationship(
621+
"NMA_SurfaceWaterPhotos",
622+
back_populates="surface_water_data",
623+
cascade="all, delete-orphan",
624+
passive_deletes=True,
625+
)
620626

621627
@validates("thing_id")
622628
def validate_thing_id(self, key, value):
@@ -632,7 +638,7 @@ class NMA_SurfaceWaterPhotos(Base):
632638
"""
633639
Legacy SurfaceWaterPhotos table from NM_Aquifer.
634640
635-
Note: This table is OUT OF SCOPE for refactoring (not a Thing child).
641+
Note: This table is a child of NMA_SurfaceWaterData via SurfaceID.
636642
"""
637643

638644
__tablename__ = "NMA_SurfaceWaterPhotos"
@@ -643,21 +649,39 @@ class NMA_SurfaceWaterPhotos(Base):
643649
)
644650

645651
# FK
646-
# FK not assigned.
652+
surface_id: Mapped[uuid.UUID] = mapped_column(
653+
"SurfaceID",
654+
UUID(as_uuid=True),
655+
ForeignKey("NMA_SurfaceWaterData.SurfaceID", ondelete="CASCADE"),
656+
nullable=False,
657+
)
647658

648659
# Legacy PK (for audit)
649660
# Current `global_id` is also the original PK in the legacy DB
650661

651662
# Legacy FK (for audit)
652-
surface_id: Mapped[Optional[uuid.UUID]] = mapped_column(
653-
"SurfaceID", UUID(as_uuid=True)
654-
)
663+
# surface_id is also the legacy FK in the source table.
655664

656665
# Additional columns
657666
point_id: Mapped[str] = mapped_column("PointID", String(50), nullable=False)
658667
ole_path: Mapped[Optional[str]] = mapped_column("OLEPath", String(50))
659668
object_id: Mapped[Optional[int]] = mapped_column("OBJECTID", Integer, unique=True)
660669

670+
# Relationships
671+
surface_water_data: Mapped["NMA_SurfaceWaterData"] = relationship(
672+
"NMA_SurfaceWaterData", back_populates="surface_water_photos"
673+
)
674+
675+
@validates("surface_id")
676+
def validate_surface_id(self, key, value):
677+
"""Prevent orphan NMA_SurfaceWaterPhotos - must have a parent NMA_SurfaceWaterData."""
678+
if value is None:
679+
raise ValueError(
680+
"NMA_SurfaceWaterPhotos requires a parent NMA_SurfaceWaterData "
681+
"(surface_id cannot be None)"
682+
)
683+
return value
684+
661685

662686
class NMA_WeatherData(Base):
663687
"""
@@ -678,7 +702,7 @@ class NMA_WeatherData(Base):
678702

679703
# Legacy PK (for audit)
680704
weather_id: Mapped[Optional[uuid.UUID]] = mapped_column(
681-
"WeatherID", UUID(as_uuid=True)
705+
"WeatherID", UUID(as_uuid=True), unique=True
682706
)
683707

684708
# Legacy FK (for audit)
@@ -691,6 +715,12 @@ class NMA_WeatherData(Base):
691715

692716
# Relationships
693717
thing: Mapped["Thing"] = relationship("Thing", back_populates="weather_data")
718+
weather_photos: Mapped[list["NMA_WeatherPhotos"]] = relationship(
719+
"NMA_WeatherPhotos",
720+
back_populates="weather_data",
721+
cascade="all, delete-orphan",
722+
passive_deletes=True,
723+
)
694724

695725
@validates("thing_id")
696726
def validate_thing_id(self, key, value):
@@ -706,7 +736,7 @@ class NMA_WeatherPhotos(Base):
706736
"""
707737
Legacy WeatherPhotos table from NM_Aquifer.
708738
709-
Note: This table is OUT OF SCOPE for refactoring (not a Thing child).
739+
Note: This table is a child of NMA_WeatherData via WeatherID.
710740
"""
711741

712742
__tablename__ = "NMA_WeatherPhotos"
@@ -717,21 +747,39 @@ class NMA_WeatherPhotos(Base):
717747
)
718748

719749
# FK:
720-
# FK not assigned.
750+
weather_id: Mapped[uuid.UUID] = mapped_column(
751+
"WeatherID",
752+
UUID(as_uuid=True),
753+
ForeignKey("NMA_WeatherData.WeatherID", ondelete="CASCADE"),
754+
nullable=False,
755+
)
721756

722757
# Legacy PK (for audit):
723758
# Current `global_id` is also the original PK in the legacy DB
724759

725760
# Legacy FK (for audit):
726-
weather_id: Mapped[Optional[uuid.UUID]] = mapped_column(
727-
"WeatherID", UUID(as_uuid=True)
728-
)
761+
# weather_id is also the legacy FK in the source table.
729762

730763
# Additional columns
731764
point_id: Mapped[str] = mapped_column("PointID", String(50), nullable=False)
732765
ole_path: Mapped[Optional[str]] = mapped_column("OLEPath", String(50))
733766
object_id: Mapped[Optional[int]] = mapped_column("OBJECTID", Integer, unique=True)
734767

768+
# Relationships
769+
weather_data: Mapped["NMA_WeatherData"] = relationship(
770+
"NMA_WeatherData", back_populates="weather_photos"
771+
)
772+
773+
@validates("weather_id")
774+
def validate_weather_id(self, key, value):
775+
"""Prevent orphan NMA_WeatherPhotos - must have a parent NMA_WeatherData."""
776+
if value is None:
777+
raise ValueError(
778+
"NMA_WeatherPhotos requires a parent NMA_WeatherData "
779+
"(weather_id cannot be None)"
780+
)
781+
return value
782+
735783

736784
class NMA_Soil_Rock_Results(Base):
737785
"""

tests/test_surface_water_photos_legacy.py

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,43 @@
2828
from uuid import uuid4
2929

3030
from db.engine import session_ctx
31-
from db.nma_legacy import NMA_SurfaceWaterPhotos
31+
from db.nma_legacy import NMA_SurfaceWaterData, NMA_SurfaceWaterPhotos
32+
from db.thing import Thing
3233

3334

34-
def test_create_surface_water_photos_all_fields():
35+
def _next_object_id() -> int:
36+
# Use a negative value to avoid collisions with existing legacy OBJECTIDs.
37+
return -(uuid4().int % 2_000_000_000)
38+
39+
40+
def _attach_thing_with_location(session, water_well_thing):
41+
location_id = uuid4()
42+
thing = session.get(Thing, water_well_thing.id)
43+
thing.nma_pk_location = str(location_id)
44+
session.commit()
45+
return thing, location_id
46+
47+
48+
def _create_surface_water_data(session, water_well_thing):
49+
thing, location_id = _attach_thing_with_location(session, water_well_thing)
50+
record = NMA_SurfaceWaterData(
51+
location_id=location_id,
52+
thing_id=thing.id,
53+
surface_id=uuid4(),
54+
point_id="SW-1000",
55+
object_id=_next_object_id(),
56+
)
57+
session.add(record)
58+
session.commit()
59+
return record
60+
61+
62+
def test_create_surface_water_photos_all_fields(water_well_thing):
3563
"""Test creating a surface water photos record with all fields."""
3664
with session_ctx() as session:
65+
parent = _create_surface_water_data(session, water_well_thing)
3766
record = NMA_SurfaceWaterPhotos(
38-
surface_id=uuid4(),
67+
surface_id=parent.surface_id,
3968
point_id="SW-0001",
4069
ole_path="photo.jpg",
4170
object_id=123,
@@ -52,14 +81,17 @@ def test_create_surface_water_photos_all_fields():
5281
assert record.object_id == 123
5382

5483
session.delete(record)
84+
session.delete(parent)
5585
session.commit()
5686

5787

58-
def test_create_surface_water_photos_minimal():
88+
def test_create_surface_water_photos_minimal(water_well_thing):
5989
"""Test creating a surface water photos record with required fields only."""
6090
with session_ctx() as session:
91+
parent = _create_surface_water_data(session, water_well_thing)
6192
record = NMA_SurfaceWaterPhotos(
6293
point_id="SW-0002",
94+
surface_id=parent.surface_id,
6395
global_id=uuid4(),
6496
)
6597
session.add(record)
@@ -68,11 +100,12 @@ def test_create_surface_water_photos_minimal():
68100

69101
assert record.global_id is not None
70102
assert record.point_id == "SW-0002"
71-
assert record.surface_id is None
103+
assert record.surface_id is not None
72104
assert record.ole_path is None
73105
assert record.object_id is None
74106

75107
session.delete(record)
108+
session.delete(parent)
76109
session.commit()
77110

78111

0 commit comments

Comments
 (0)