From 3fcccb495ab936be59156609dfeefb9f77adb7e3 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 3 Sep 2025 14:34:33 -0600 Subject: [PATCH 1/9] feat: add missing fields from nm aquifer to contact --- db/contact.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/db/contact.py b/db/contact.py index 217582d44..5a54a67ae 100644 --- a/db/contact.py +++ b/db/contact.py @@ -34,14 +34,18 @@ class ThingContactAssociation(Base, AutoBaseMixin): class Contact(Base, AutoBaseMixin, ReleaseMixin): - name = Column(String(100), nullable=False) + name = Column(String(100), nullable=True) role = lexicon_term(nullable=False) + organization = Column(String(100), nullable=True) + nma_pk_owners = Column(String(100), nullable=True) phones = relationship("Phone", back_populates="contact", passive_deletes=True) emails = relationship("Email", back_populates="contact", passive_deletes=True) addresses = relationship("Address", back_populates="contact", passive_deletes=True) - search_vector = Column(TSVectorType("name", "role")) + search_vector = Column( + TSVectorType("name", "role", "organization", "nma_pk_owners") + ) author_associations = relationship( "AuthorContactAssociation", From e3a834c4a12d4c4b0182684825f9d6b1953d67f7 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 3 Sep 2025 14:35:11 -0600 Subject: [PATCH 2/9] feat: ensure name or organization are populated --- schemas/contact.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/schemas/contact.py b/schemas/contact.py index 454af0366..7f3baecc7 100644 --- a/schemas/contact.py +++ b/schemas/contact.py @@ -18,7 +18,7 @@ import phonenumbers from email_validator import validate_email, EmailNotValidError from phonenumbers import NumberParseException -from pydantic import field_validator, BaseModel +from pydantic import field_validator, BaseModel, model_validator from schemas import BaseResponseModel, BaseCreateModel, BaseUpdateModel from schemas.thing import ThingResponse @@ -117,8 +117,9 @@ class CreateContact(BaseCreateModel): """ thing_id: int - name: str + name: str | None = None role: str + organization: str | None = None # description: str | None = None # email: str | None = None # phone: str | None = None @@ -127,6 +128,12 @@ class CreateContact(BaseCreateModel): phones: list[CreatePhone] | None = None addresses: list[CreateAddress] | None = None + @model_validator(mode="before") + def check_empty(data: dict) -> dict: + if data.get("name", None) is None and data.get("organization", None) is None: + raise ValueError("Either name or organization must be provided.") + return data + # -------- RESPONSE ---------- From bde2d195f8ecc4a0c7f9c6dc7dcd8c6df7329a66 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 3 Sep 2025 14:35:38 -0600 Subject: [PATCH 3/9] test: update tests for new fields/schemas --- tests/conftest.py | 2 ++ tests/test_contact.py | 11 ++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index a86863c04..c66433782 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -262,6 +262,7 @@ def contact(water_well_thing): release_status="private", name="Test Contact", role="Owner", + organization="Test Organization", ) session.add(contact) session.commit() @@ -334,6 +335,7 @@ def second_contact(): release_status="private", name="Test Second Contact", role="Owner", + organization="Test Second Organization", ) session.add(contact) session.commit() diff --git a/tests/test_contact.py b/tests/test_contact.py index 1173a702d..85c7a7b48 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -6,7 +6,7 @@ from db import Contact, Address, Email, Phone from main import app from tests import client, cleanup_post_test, cleanup_patch_test, override_authentication -from schemas.contact import ValidateEmail, ValidatePhone +from schemas.contact import ValidateEmail, ValidatePhone, CreateContact import pytest from pydantic import ValidationError @@ -32,6 +32,13 @@ def override_authentication_dependency_fixture(): # VALIDATION tests ============================================================= +def test_check_empty_fields(): + with pytest.raises( + ValueError, match="Either name or organization must be provided." + ): + CreateContact(name=None, organization=None) + + def test_validate_phone(): for phone in [ "definitely not a phone", @@ -64,6 +71,7 @@ def test_add_contact(spring_thing): "release_status": "private", "name": "Test Contact 2", "role": "Owner", + "organization": "Well Owner LLC", "thing_id": spring_thing.id, "emails": [ { @@ -148,6 +156,7 @@ def test_add_contact_409_bad_thing_id(): "release_status": "private", "name": "Test Contact 3", "role": "Owner", + "organization": "Well Owner LLC", "thing_id": bad_thing_id, "emails": [ { From 2426eaad77ef04c150da5b1acfd95a98b4f6a721 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 3 Sep 2025 14:57:03 -0600 Subject: [PATCH 4/9] feat: update contact transfer for new fields --- transfers/owner_transfer.py | 118 +++++++++++++++++++++--------------- 1 file changed, 68 insertions(+), 50 deletions(-) diff --git a/transfers/owner_transfer.py b/transfers/owner_transfer.py index 3c2cae3d8..a6782306c 100644 --- a/transfers/owner_transfer.py +++ b/transfers/owner_transfer.py @@ -49,66 +49,84 @@ def transfer_owners(session): role = "Primary" # TODO: put in guards for null values - contact1 = Contact( - name=f"{row.FirstName} {row.LastName}", - role=role, - # TODO: needs to be implemented - # organization_name=company - ) - assoc = ThingContactAssociation() - assoc.thing = thing - assoc.contact = contact1 - session.add(assoc) - session.add(contact1) - - if row.Email: - contact1.emails.append(Email(email=row.Email, email_type="Primary")) - if row.Phone: - contact1.phones.append(Phone(phone_number=row.Phone, phone_type="Primary")) - if row.CellPhone: - contact1.phones.append( - Phone(phone_number=row.CellPhone, phone_type="Mobile") + # name OR organization must be defined, otherwise skip + if not (row.FirstName or row.LastName) and not row.Company: + print( + f"Skipping first contact for PointID {row.PointID} due to missing name and organization." ) + else: + print(f"Transferring first contact for PointID {row.PointID}") + contact1 = Contact( + name=f"{row.FirstName} {row.LastName}", + role=role, + organization=row.Company, # assumes organization applies to both contacts + nma_pk_owners=row.OwnerKey, + ) + assoc = ThingContactAssociation() + assoc.thing = thing + assoc.contact = contact1 + session.add(assoc) + session.add(contact1) + + if row.Email: + contact1.emails.append(Email(email=row.Email, email_type="Primary")) + if row.Phone: + contact1.phones.append( + Phone(phone_number=row.Phone, phone_type="Primary") + ) + if row.CellPhone: + contact1.phones.append( + Phone(phone_number=row.CellPhone, phone_type="Mobile") + ) - if row.MailingAddress: - contact1.addresses.append( - Address( - address_line_1=row.MailingAddress, - city=row.MailCity, - state=row.MailState, - postal_code=row.MailZipCode, - address_type="Mailing", + if row.MailingAddress: + contact1.addresses.append( + Address( + address_line_1=row.MailingAddress, + city=row.MailCity, + state=row.MailState, + postal_code=row.MailZipCode, + address_type="Mailing", + ) ) - ) - contact1.addresses.append( - Address( - address_line_1=row.PhysicalAddress, - city=row.PhysicalCity, - state=row.PhysicalState, - postal_code=row.PhysicalZipCode, - address_type="Physical", + contact1.addresses.append( + Address( + address_line_1=row.PhysicalAddress, + city=row.PhysicalCity, + state=row.PhysicalState, + postal_code=row.PhysicalZipCode, + address_type="Physical", + ) ) - ) # TODO: put in guards for null values - contact2 = Contact( - name=f"{row.SecondFirstName} {row.SecondLastName}", role="Secondary" - ) - if row.SecondCtctEmail: - contact2.emails.append( - Email(email=row.SecondCtctEmail, email_type="Primary") + if not (row.SecondFirstName or row.SecondLastName) and not row.Company: + print( + f"Skipping second contact for PointID {row.PointID} due to missing name and organization." ) - if row.SecondCtctPhone: - contact2.phones.append( - Phone(phone_number=row.SecondCtctPhone, phone_type="Primary") + else: + print(f"Transferring second contact for PointID {row.PointID}") + contact2 = Contact( + name=f"{row.SecondFirstName} {row.SecondLastName}", + role="Secondary", + organization=row.Company, # Assumes organization applies to both contacts + nma_pk_owners=row.OwnerKey, ) + if row.SecondCtctEmail: + contact2.emails.append( + Email(email=row.SecondCtctEmail, email_type="Primary") + ) + if row.SecondCtctPhone: + contact2.phones.append( + Phone(phone_number=row.SecondCtctPhone, phone_type="Primary") + ) - assoc = ThingContactAssociation() - assoc.thing = thing - assoc.contact = contact2 - session.add(assoc) - session.add(contact2) + assoc = ThingContactAssociation() + assoc.thing = thing + assoc.contact = contact2 + session.add(assoc) + session.add(contact2) session.commit() From b422780824fbe4ed03c815ac9db1ff5ad0dc7b55 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 3 Sep 2025 14:59:01 -0600 Subject: [PATCH 5/9] refactor: rename owners to contacts for consistent style --- transfers/{owner_transfer.py => contact_transfer.py} | 2 +- transfers/transfer.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) rename transfers/{owner_transfer.py => contact_transfer.py} (99%) diff --git a/transfers/owner_transfer.py b/transfers/contact_transfer.py similarity index 99% rename from transfers/owner_transfer.py rename to transfers/contact_transfer.py index a6782306c..4df88ec68 100644 --- a/transfers/owner_transfer.py +++ b/transfers/contact_transfer.py @@ -32,7 +32,7 @@ def extract_owner_role(comment): return "Primary" -def transfer_owners(session): +def transfer_contacts(session): odf = read_csv("ownersdata.csv") odf = odf.replace(pd.NA, None) diff --git a/transfers/transfer.py b/transfers/transfer.py index f6943b883..b29f2bd59 100644 --- a/transfers/transfer.py +++ b/transfers/transfer.py @@ -21,11 +21,11 @@ from transfers.asset_transfer import transfer_assets_testing from transfers.group_transfer import transfer_groups from transfers.link_ids_transfer import transfer_link_ids, transfer_link_ids_welldata -from transfers.owner_transfer import transfer_owners +from transfers.contact_transfer import transfer_contacts from transfers.sensor_transfer import init_sensor from transfers.waterlevels_transfer import transfer_water_levels -from transfers.well_transfer import transfer_wells, transfer_wellscreens, cleanup_wells +from transfers.well_transfer import transfer_wells, transfer_wellscreens from transfers.thing_transfer import ( transfer_springs, transfer_perennial_stream, @@ -50,7 +50,7 @@ def main_transfer(): transfer_perennial_stream_flag = False transfer_ephemeral_stream_flag = False transfer_met_flag = False - transfer_owners_flag = False + transfer_contacts_flag = False transfer_waterlevels_flag = False transfer_link_ids_flag = False transfer_assets_flag = False @@ -80,8 +80,8 @@ def main_transfer(): if init or transfer_met_flag: transfer_met(sess, limit) - if init or transfer_owners_flag: - transfer_owners(sess) + if init or transfer_contacts_flag: + transfer_contacts(sess) if init or transfer_waterlevels_flag: transfer_water_levels(sess) From 92f6268cb8f95be561f8b155e977d8c3eb27574a Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 3 Sep 2025 15:06:01 -0600 Subject: [PATCH 6/9] refactor: enable transfers to be called from root dir --- transfers/asset_transfer.py | 2 +- transfers/util.py | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/transfers/asset_transfer.py b/transfers/asset_transfer.py index 804964505..a9682797a 100644 --- a/transfers/asset_transfer.py +++ b/transfers/asset_transfer.py @@ -55,7 +55,7 @@ def transfer_assets(session: Session) -> None: def transfer_assets_testing(session: Session) -> None: for p in ("asset1.png", "asset2.png", "asset3.png"): - with open(f"./data/assets/{p}", "rb") as f: + with open(f"./transfers/data/assets/{p}", "rb") as f: uf = UploadFile(file=f, filename=p, size=10) uri, blob_name = gcs_upload(uf) thing_id = 151 diff --git a/transfers/util.py b/transfers/util.py index 953e448f8..4b4cec3bb 100644 --- a/transfers/util.py +++ b/transfers/util.py @@ -30,12 +30,7 @@ def read_csv(name: str) -> pd.DataFrame: - p = Path(".") / "data" / name - return pd.read_csv(p) - - -def read_csv(name: str) -> pd.DataFrame: - p = Path(".") / "data" / name + p = Path(".") / "transfers" / "data" / name return pd.read_csv(p) From ddbb9f648cf824bea4841e78791990591cd5e3d2 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 3 Sep 2025 15:13:46 -0600 Subject: [PATCH 7/9] feat: added clear print statements to help navigate transfers --- transfers/transfer.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/transfers/transfer.py b/transfers/transfer.py index b29f2bd59..1eb55b841 100644 --- a/transfers/transfer.py +++ b/transfers/transfer.py @@ -65,35 +65,45 @@ def main_transfer(): erase_and_initalize(sess) if init or transfer_well_flag: + print("\n", "*" * 10, "TRANSFERRING WELLS", "*" * 10) transfer_wells(sess, limit) transfer_wellscreens(sess) if init or transfer_spring_flag: + print("\n", "*" * 10, "TRANSFERRING SPRINGS", "*" * 10) transfer_springs(sess, limit) if init or transfer_perennial_stream_flag: + print("\n", "*" * 10, "TRANSFERRING PERENNIAL STREAMS", "*" * 10) transfer_perennial_stream(sess, limit) if init or transfer_ephemeral_stream_flag: + print("\n", "*" * 10, "TRANSFERRING EPHEMERAL STREAMS", "*" * 10) transfer_ephemeral_stream(sess, limit) if init or transfer_met_flag: + print("\n", "*" * 10, "TRANSFERRING METEOROLOGICAL", "*" * 10) transfer_met(sess, limit) if init or transfer_contacts_flag: + print("\n", "*" * 10, "TRANSFERRING CONTACTS", "*" * 10) transfer_contacts(sess) if init or transfer_waterlevels_flag: + print("\n", "*" * 10, "TRANSFERRING WATER LEVELS", "*" * 10) transfer_water_levels(sess) if init or transfer_link_ids_flag: + print("\n", "*" * 10, "TRANSFERRING LINK IDS", "*" * 10) transfer_link_ids(sess) transfer_link_ids_welldata(sess) if init or transfer_assets_flag: + print("\n", "*" * 10, "TRANSFERRING ASSETS", "*" * 10) transfer_assets_testing(sess) if init or transfer_groups_flag: + print("\n", "*" * 10, "TRANSFERRING GROUPS", "*" * 10) transfer_groups(sess) # if init or cleanup_wells_flag: From 8ca80e11ca5f04d7a506dbd943410e4b71cb46f1 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 3 Sep 2025 15:28:55 -0600 Subject: [PATCH 8/9] feat: add company to owners data --- transfers/data/ownersdata.csv | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/transfers/data/ownersdata.csv b/transfers/data/ownersdata.csv index 8ed049ac2..8ea12f7d4 100644 --- a/transfers/data/ownersdata.csv +++ b/transfers/data/ownersdata.csv @@ -1,4 +1,4 @@ -PointID,FirstName,LastName,OwnerKey,Email,CellPhone,Phone,MailingAddress,MailCity,MailState,MailZipCode,PhysicalAddress,PhysicalCity,PhysicalState,PhysicalZipCode,SecondLastName,SecondFirstName,SecondCtctEmail,SecondCtctPhone -EB-270,John,Doe,OWN001,john.doe@example.com,555-1234,555-5678,123 Main St,Albuquerque,NM,87101,456 Elm St,Albuquerque,NM,87102,Smith,Jane,jane.smith@example.com,555-8765 -EB-271,Alice,Johnson,OWN002,alice.j@example.com,555-2345,555-6789,789 Oak St,Socorro,NM,87801,101 Pine St,Socorro,NM,87802,Brown,Bob,bob.brown@example.com,555-4321 -EB-272,Maria,Gonzalez,OWN003,maria.g@example.com,555-3456,555-7890,234 Maple St,Las Cruces,NM,88001,567 Cedar St,Las Cruces,NM,88002,Lopez,Carlos,carlos.lopez@example.com,555-5432 \ No newline at end of file +PointID,FirstName,LastName,OwnerKey,Company,Email,CellPhone,Phone,MailingAddress,MailCity,MailState,MailZipCode,PhysicalAddress,PhysicalCity,PhysicalState,PhysicalZipCode,SecondLastName,SecondFirstName,SecondCtctEmail,SecondCtctPhone +QU-047,John,Doe,OWN001,JD LLC,john.doe@example.com,555-1234,555-5678,123 Main St,Albuquerque,NM,87101,456 Elm St,Albuquerque,NM,87102,Smith,Jane,jane.smith@example.com,555-8765 +NM-11224,Alice,Johnson,OWN002,AJ LLC,alice.j@example.com,555-2345,555-6789,789 Oak St,Socorro,NM,87801,101 Pine St,Socorro,NM,87802,Brown,Bob,bob.brown@example.com,555-4321 +NM-21466,Maria,Gonzalez,OWN003,MG LLC,maria.g@example.com,555-3456,555-7890,234 Maple St,Las Cruces,NM,88001,567 Cedar St,Las Cruces,NM,88002,Lopez,Carlos,carlos.lopez@example.com,555-5432 \ No newline at end of file From a08439e1588edb0ead45103e14faeb9aae905eec Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 3 Sep 2025 16:04:24 -0600 Subject: [PATCH 9/9] feat: ensure name/organization are not both null on update --- api/contact.py | 35 +++++++++++++++++++++++++++++++++++ schemas/contact.py | 1 + tests/conftest.py | 21 ++++++++++++++++++++- tests/test_contact.py | 30 ++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 1 deletion(-) diff --git a/api/contact.py b/api/contact.py index 0b4c99782..ba597c881 100644 --- a/api/contact.py +++ b/api/contact.py @@ -307,6 +307,41 @@ async def update_contact( :param session: Database session :return: Updated contact response """ + contact = simple_get_by_id(session, Contact, contact_id) + + if ( + contact.name is None + and contact_data.name is None + and contact_data.organization is None + ): + raise PydanticStyleException( + status_code=status.HTTP_409_CONFLICT, + detail=[ + { + "loc": ["body", "organization"], + "msg": "organization cannot be None if name is None.", + "type": "value_error", + "input": {"organization": contact_data.organization}, + } + ], + ) + elif ( + contact.organization is None + and contact_data.organization is None + and contact_data.name is None + ): + raise PydanticStyleException( + status_code=status.HTTP_409_CONFLICT, + detail=[ + { + "loc": ["body", "name"], + "msg": "name cannot be None if organization is None.", + "type": "value_error", + "input": {"name": contact_data.name}, + } + ], + ) + return model_patcher(session, Contact, contact_id, contact_data, user=user) diff --git a/schemas/contact.py b/schemas/contact.py index 7f3baecc7..f9de822cf 100644 --- a/schemas/contact.py +++ b/schemas/contact.py @@ -206,6 +206,7 @@ class UpdateContact(BaseUpdateModel): name: str | None = None role: str | None = None thing_id: int | None = None + organization: str | None = None # email: str | None = None # phone: str | None = None # address: str | None = None diff --git a/tests/conftest.py b/tests/conftest.py index c66433782..5be97022d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -335,7 +335,7 @@ def second_contact(): release_status="private", name="Test Second Contact", role="Owner", - organization="Test Second Organization", + organization=None, ) session.add(contact) session.commit() @@ -403,6 +403,25 @@ def second_address(second_contact): session.commit() +@pytest.fixture(scope="function") +def third_contact(): + with session_ctx() as session: + contact = Contact( + release_status="private", + name=None, + role="Owner", + organization="Third Organization", + ) + session.add(contact) + session.commit() + session.refresh(contact) + + yield contact + + session.delete(contact) + session.commit() + + @pytest.fixture(scope="session") def asset(): with session_ctx() as session: diff --git a/tests/test_contact.py b/tests/test_contact.py index 85c7a7b48..12e7705a9 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -725,6 +725,36 @@ def test_patch_contact_404_not_found(contact): assert data["detail"] == f"Contact with ID {bad_contact_id} not found." +def test_patch_contact_409_null_name(second_contact): + payload = {"name": None} + response = client.patch( + f"/contact/{second_contact.id}", + json=payload, + ) + + assert response.status_code == 409 + data = response.json() + assert data["detail"][0]["loc"] == ["body", "name"] + assert data["detail"][0]["msg"] == "name cannot be None if organization is None." + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"name": None} + + +def test_patch_contact_409_null_organization(third_contact): + payload = {"organization": None} + response = client.patch( + f"/contact/{third_contact.id}", + json=payload, + ) + + assert response.status_code == 409 + data = response.json() + assert data["detail"][0]["loc"] == ["body", "organization"] + assert data["detail"][0]["msg"] == "organization cannot be None if name is None." + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"organization": None} + + def test_patch_email(email): payload = {"email": "boo@bar.com", "release_status": "archived"} response = client.patch(f"/contact/email/{email.id}", json=payload)