Skip to content
35 changes: 35 additions & 0 deletions api/contact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be

if contact_data.name is None and contact_data.organization is None:
   if contact.name is None:
      ....
   elif contact.organization 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)


Expand Down
8 changes: 6 additions & 2 deletions db/contact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should think about whether or not we should make nma_pk_owners searchable

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That data is encoded in either the name or organization, so that may be redundant and lead to confusion... It's also not returned in any responses. I'll remove this in a PR. Should role be searchable too? That's not really unique to a particular contact (different contacts can have the same roles).

@jirhiker jirhiker Sep 3, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets leave role it in for now, but i get your point. Someone probably won't search by the "role" keyword


author_associations = relationship(
"AuthorContactAssociation",
Expand Down
12 changes: 10 additions & 2 deletions schemas/contact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 ----------

Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -334,6 +335,7 @@ def second_contact():
release_status="private",
name="Test Second Contact",
role="Owner",
organization=None,
)
session.add(contact)
session.commit()
Expand Down Expand Up @@ -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:
Expand Down
41 changes: 40 additions & 1 deletion tests/test_contact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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": [
{
Expand Down Expand Up @@ -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": [
{
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion transfers/asset_transfer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
134 changes: 134 additions & 0 deletions transfers/contact_transfer.py
Original file line number Diff line number Diff line change
@@ -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 =============================================
8 changes: 4 additions & 4 deletions transfers/data/ownersdata.csv
Original file line number Diff line number Diff line change
@@ -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
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
Loading
Loading