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/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", diff --git a/schemas/contact.py b/schemas/contact.py index 454af0366..f9de822cf 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 ---------- @@ -199,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 a86863c04..5be97022d 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=None, ) session.add(contact) session.commit() @@ -401,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 1173a702d..12e7705a9 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": [ { @@ -716,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) 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/contact_transfer.py b/transfers/contact_transfer.py new file mode 100644 index 000000000..4df88ec68 --- /dev/null +++ b/transfers/contact_transfer.py @@ -0,0 +1,134 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +import numpy as np +import pandas as pd +from transfers.util import read_csv, filter_to_valid_point_ids +from db import Thing, Contact, ThingContactAssociation, Email, Phone, Address + + +def extract_owner_role(comment): + # if comment is None: + # return "Owner" + # if "Owner" in comment: + # return "Owner" + # if "Manager" in comment: + # return "Manager" + # if "Director" in comment: + # return "Director" + + return "Primary" + + +def transfer_contacts(session): + + odf = read_csv("ownersdata.csv") + odf = odf.replace(pd.NA, None) + odf = odf.replace({np.nan: None}) + odf = filter_to_valid_point_ids(session, odf) + for i, row in odf.iterrows(): + thing = session.query(Thing).where(Thing.name == row.PointID).first() + if thing is None: + print(f"Thing with PointID {row.PointID} not foaund. Skipping owner.") + continue + + # TODO: extract role from OwnerComment + # role = extract_owner_role(row.OwnerComment) + role = "Primary" + + # TODO: put in guards for null values + # 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", + ) + ) + + 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 + 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." + ) + 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) + + session.commit() + + +# ============= EOF ============================================= 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 diff --git a/transfers/owner_transfer.py b/transfers/owner_transfer.py deleted file mode 100644 index 3c2cae3d8..000000000 --- a/transfers/owner_transfer.py +++ /dev/null @@ -1,116 +0,0 @@ -# =============================================================================== -# Copyright 2025 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== -import numpy as np -import pandas as pd -from transfers.util import read_csv, filter_to_valid_point_ids -from db import Thing, Contact, ThingContactAssociation, Email, Phone, Address - - -def extract_owner_role(comment): - # if comment is None: - # return "Owner" - # if "Owner" in comment: - # return "Owner" - # if "Manager" in comment: - # return "Manager" - # if "Director" in comment: - # return "Director" - - return "Primary" - - -def transfer_owners(session): - - odf = read_csv("ownersdata.csv") - odf = odf.replace(pd.NA, None) - odf = odf.replace({np.nan: None}) - odf = filter_to_valid_point_ids(session, odf) - for i, row in odf.iterrows(): - thing = session.query(Thing).where(Thing.name == row.PointID).first() - if thing is None: - print(f"Thing with PointID {row.PointID} not foaund. Skipping owner.") - continue - - # TODO: extract role from OwnerComment - # role = extract_owner_role(row.OwnerComment) - 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") - ) - - 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", - ) - ) - - # 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 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) - - session.commit() - - -# ============= EOF ============================================= diff --git a/transfers/transfer.py b/transfers/transfer.py index f6943b883..1eb55b841 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 @@ -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_owners_flag: - transfer_owners(sess) + 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: 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)