From 15188af5c7a88390b5b05ff4d04bea7e3dc04117 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 11:59:48 -0600 Subject: [PATCH 01/56] refactor: update contact schemas - implement Validate schema for create and update schemas - use "| None" instead of Optional for style consistency --- schemas/contact.py | 142 +++++++++++++++++---------------------------- 1 file changed, 53 insertions(+), 89 deletions(-) diff --git a/schemas/contact.py b/schemas/contact.py index 522abf153..debaada17 100644 --- a/schemas/contact.py +++ b/schemas/contact.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from typing import Optional, List +from typing import List import phonenumbers from email_validator import validate_email, EmailNotValidError @@ -23,16 +23,45 @@ from schemas import ORMBaseModel from schemas.thing import ThingResponse -""" -REFACTOR TODO -Create common validator classes to be shared amongst create and update schemas. -Since many fields are optional in the update schemas set check_fields=False in the field_validator. -""" +# -------- VALIDATORS ---------- + + +class ValidateEmail(BaseModel): + + @field_validator("email", check_fields=False) + @classmethod + def validate_email(cls, email: str | None) -> str | None: + if email is not None: + try: + emailinfo = validate_email(email, check_deliverability=False) + return emailinfo.normalized + except EmailNotValidError as e: + raise ValueError(f"Invalid email format. {email}") + + +class ValidatePhone(BaseModel): + + @field_validator("phone_number", check_fields=False) + @classmethod + def validate_phone(cls, phone_number_str: str | None) -> str | None: + if phone_number_str is not None: + region = "US" + try: + parsed_number = phonenumbers.parse(phone_number_str, region) + if phonenumbers.is_valid_number(parsed_number): + formatted_number = phonenumbers.format_number( + parsed_number, phonenumbers.PhoneNumberFormat.E164 + ) + return formatted_number + else: + raise ValueError(f"Invalid phone number. {phone_number_str}") + except NumberParseException as e: + raise ValueError(f"Invalid phone number. {phone_number_str}") # -------- CREATE ---------- -class CreateEmail(BaseModel): +class CreateEmail(ValidateEmail): """ Schema for creating an email. """ @@ -40,17 +69,8 @@ class CreateEmail(BaseModel): email: str email_type: str = "Primary" # Default to 'Primary' - @field_validator("email") - @classmethod - def validate_email(cls, email): - try: - emailinfo = validate_email(email, check_deliverability=False) - return emailinfo.normalized - except EmailNotValidError as e: - raise ValueError(f"Invalid email format. {email}") - -class CreatePhone(BaseModel): +class CreatePhone(ValidatePhone): """ Schema for creating a phone number. """ @@ -58,22 +78,6 @@ class CreatePhone(BaseModel): phone_number: str phone_type: str = "Primary" # Default to 'Primary' - @field_validator("phone_number", mode="before") - @classmethod - def validate_phone(cls, phone_number_str): - region = "US" - try: - parsed_number = phonenumbers.parse(phone_number_str, region) - if phonenumbers.is_valid_number(parsed_number): - formatted_number = phonenumbers.format_number( - parsed_number, phonenumbers.PhoneNumberFormat.E164 - ) - return formatted_number - else: - raise ValueError(f"Invalid phone number. {phone_number_str}") - except NumberParseException as e: - raise ValueError(f"Invalid phone number. {phone_number_str}") - class CreateAddress(BaseModel): """ @@ -108,46 +112,6 @@ class CreateContact(BaseModel): addresses: list[CreateAddress] | None = None -# -# -# @field_validator("phone", mode="before") -# @classmethod -# def validate_phone(cls, phone_number_str): -# region = "US" -# try: -# parsed_number = phonenumbers.parse(phone_number_str, region) -# if phonenumbers.is_valid_number(parsed_number): -# # You can also format the number if needed -# formatted_number = phonenumbers.format_number( -# parsed_number, phonenumbers.PhoneNumberFormat.E164 -# ) -# return formatted_number -# else: -# raise ValueError(f"Invalid phone number. {phone_number_str}") -# except NumberParseException as e: -# raise ValueError(f"Invalid phone number. {phone_number_str}") -# -# @field_validator("email") -# @classmethod -# def validate_email(cls, email): -# # try: -# # Check that the email address is valid. Turn on check_deliverability -# # for first-time validations like on account creation pages (but not -# # login pages). -# emailinfo = validate_email(email, check_deliverability=False) -# -# # After this point, use only the normalized form of the email address, -# # especially before going to a database query. -# email = emailinfo.normalized -# return email -# # except EmailNotValidError as e: -# # if v is not None: -# # # Basic email validation -# # if not re.fullmatch(r"[^@]+@[^@]+\.[^@]+", v): -# # raise ValueError(f"Invalid email format. {v}") -# # return v - - # -------- RESPONSE ---------- class PhoneResponse(ORMBaseModel): """ @@ -205,32 +169,32 @@ class UpdateContact(BaseModel): Schema for updating contact information. """ - name: Optional[str] = None - role: Optional[str] = None - # thing_id: int | None = None + name: str | None = None + role: str | None = None + thing_id: int | None = None # email: str | None = None # phone: str | None = None # address: str | None = None -class UpdateEmail(BaseModel): +class UpdateEmail(ValidateEmail): """ Schema for updating email information. """ # email: Annotated[Optional[str], None] # email_type: Annotated[Optional[str], None] - email: Optional[str] = None # None - email_type: Optional[str] = None # None + email: str | None = None # None + email_type: str | None = None # None -class UpdatePhone(BaseModel): +class UpdatePhone(ValidatePhone): """ Schema for updating phone information. """ - phone_number: Optional[str] = None - phone_type: Optional[str] = None + phone_number: str | None = None + phone_type: str | None = None class UpdateAddress(BaseModel): @@ -238,13 +202,13 @@ class UpdateAddress(BaseModel): Schema for updating address information. """ - address_line_1: Optional[str] = None - address_line_2: Optional[str] = None - city: Optional[str] = None - state: Optional[str] = None - postal_code: Optional[str] = None - country: Optional[str] = None - address_type: Optional[str] = None + address_line_1: str | None = None + address_line_2: str | None = None + city: str | None = None + state: str | None = None + postal_code: str | None = None + country: str | None = None + address_type: str | None = None # ============= EOF ============================================= From e6392df375b69781cd2fb8a5b50e212c12c55a15 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 13:00:30 -0600 Subject: [PATCH 02/56] refactor: created_at is inherited from ORMBaseModel --- schemas/contact.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/schemas/contact.py b/schemas/contact.py index debaada17..4984cd2cf 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, AwareDatetime +from pydantic import field_validator, BaseModel from schemas import ORMBaseModel from schemas.thing import ThingResponse @@ -156,7 +156,6 @@ class ContactResponse(ORMBaseModel): id: int name: str role: str - created_at: AwareDatetime emails: List[EmailResponse] = [] phones: List[PhoneResponse] = [] addresses: List[AddressResponse] = [] From 0e4c4e3c9eb2036f0228e43e157effc071bda8b6 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 13:01:43 -0600 Subject: [PATCH 03/56] fix: delete emails/phones/addresses/thing association upon contact deletion --- db/contact.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/db/contact.py b/db/contact.py index d3dc400d3..54ad65590 100644 --- a/db/contact.py +++ b/db/contact.py @@ -22,17 +22,21 @@ class ThingContactAssociation(Base, AutoBaseMixin): - thing_id = Column(Integer, ForeignKey("thing.id"), nullable=False) - contact_id = Column(Integer, ForeignKey("contact.id"), nullable=False) + thing_id = Column( + Integer, ForeignKey("thing.id", ondelete="CASCADE"), nullable=False + ) + contact_id = Column( + Integer, ForeignKey("contact.id", ondelete="CASCADE"), nullable=False + ) class Contact(Base, AutoBaseMixin): name = Column(String(100), nullable=False) role = lexicon_term(nullable=False) - phones = relationship("Phone", back_populates="contact") - emails = relationship("Email", back_populates="contact") - addresses = relationship("Address", back_populates="contact") + 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) # email = Column(String(100), nullable=True) # phone = Column(String(20), nullable=True) # owner_id = Column(Integer, ForeignKey("owner.id"), nullable=False) @@ -46,7 +50,9 @@ class Contact(Base, AutoBaseMixin): cascade="all, delete-orphan", ) authors = association_proxy("author_associations", "author") - things = relationship("Thing", secondary="thing_contact_association") + things = relationship( + "Thing", secondary="thing_contact_association", passive_deletes=True + ) class Phone(Base, AutoBaseMixin): From e87d52813b394a8cf82e410f74f0fe8a12e225b5 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 13:02:22 -0600 Subject: [PATCH 04/56] fix: delete contact association upon thing deletion --- db/thing.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/db/thing.py b/db/thing.py index e74965437..8c730f00d 100644 --- a/db/thing.py +++ b/db/thing.py @@ -89,6 +89,9 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin): samples = relationship( "Sample", back_populates="thing", cascade="all, delete-orphan", uselist=True ) + contacts = relationship( + "Contacts", secondary="thing_contact_association", passive_deletes=True + ) class ThingIdLink(Base, AutoBaseMixin): From 392d183d882e171ee97635778ccc83e6c6af1064 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 13:12:20 -0600 Subject: [PATCH 05/56] fix: set back_populates for correct relationships --- db/contact.py | 5 ++++- db/thing.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/db/contact.py b/db/contact.py index 54ad65590..7d1e7b4f1 100644 --- a/db/contact.py +++ b/db/contact.py @@ -51,7 +51,10 @@ class Contact(Base, AutoBaseMixin): ) authors = association_proxy("author_associations", "author") things = relationship( - "Thing", secondary="thing_contact_association", passive_deletes=True + "Thing", + secondary="thing_contact_association", + back_populates="contacts", + passive_deletes=True, ) diff --git a/db/thing.py b/db/thing.py index 8c730f00d..74055c742 100644 --- a/db/thing.py +++ b/db/thing.py @@ -90,7 +90,10 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin): "Sample", back_populates="thing", cascade="all, delete-orphan", uselist=True ) contacts = relationship( - "Contacts", secondary="thing_contact_association", passive_deletes=True + "Contact", + secondary="thing_contact_association", + back_populates="things", + passive_deletes=True, ) From 59101111f82f34286f9350bf0bb51c59e720a37d Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 13:18:13 -0600 Subject: [PATCH 06/56] refactor: update test_add_contact --- tests/test_contact.py | 97 ++++++++++++++++++++----------------------- 1 file changed, 45 insertions(+), 52 deletions(-) diff --git a/tests/test_contact.py b/tests/test_contact.py index 7657fbb27..c23eb38fc 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -1,70 +1,63 @@ -# from fastapi.testclient import TestClient -# from main import app -# from models import Base, engine -# Base.metadata.drop_all(engine) -# Base.metadata.create_all(engine) +from db import Contact -# client = TestClient(app) - -from tests import client +from tests import client, cleanup_post_test # ADD tests ====================================================== def test_add_contact(thing): - response = client.post( - "/contact", - json={ - "name": "Test Contact", - "role": "Owner", - "thing_id": thing.id, - "emails": [{"email": "fasdfasdf@gmail.com", "email_type": "Primary"}], - "phones": [{"phone_number": "+12345678901", "phone_type": "Primary"}], - "addresses": [ - { - "address_line_1": "123 Main St", - "city": "Test City", - "state": "NM", - "postal_code": "87501", - "country": "United States", - "address_type": "Primary", - } - ], - }, - ) + payload = { + "name": "Test Contact", + "role": "Owner", + "thing_id": thing.id, + "emails": [{"email": "fasdfasdf@gmail.com", "email_type": "Primary"}], + "phones": [{"phone_number": "+12345678901", "phone_type": "Primary"}], + "addresses": [ + { + "address_line_1": "123 Main St", + "address_line_2": "Apt 4B", + "city": "Test City", + "state": "NM", + "postal_code": "87501", + "country": "United States", + "address_type": "Primary", + } + ], + } + response = client.post("/contact", json=payload) data = response.json() assert response.status_code == 201 assert "id" in data - assert data["name"] == "Test Contact" - assert data["role"] == "Owner" + assert data["name"] == payload["name"] + assert data["role"] == payload["role"] assert len(data["emails"]) == 1 - assert data["emails"][0]["email"] == "fasdfasdf@gmail.com" + assert data["emails"][0]["email"] == payload["emails"][0]["email"] + assert data["emails"][0]["email_type"] == payload["emails"][0]["email_type"] assert len(data["phones"]) == 1 - assert data["phones"][0]["phone_number"] == "+12345678901" + assert data["phones"][0]["phone_number"] == payload["phones"][0]["phone_number"] + assert data["phones"][0]["phone_type"] == payload["phones"][0]["phone_type"] + assert len(data["addresses"]) == 1 - assert data["addresses"][0]["address_line_1"] == "123 Main St" - - # assert data["email"] == "fasdfasdf@gmail.com" - - # for i in range(2, 5): - # response = client.post( - # "/base/contact", - # json={ - # "owner_id": i, - # "name": f"Test Contact {i}", - # "email": f"foo{i}@gmail.com", - # "phone": f"+1234567890{i}", - # }, - # ) - # assert response.status_code == 201 - # data = response.json() - # assert "id" in data - # assert data["name"] == f"Test Contact {i}" - # assert data["email"] == f"foo{i}@gmail.com" - # assert data["phone"] == f"+1234567890{i}" + assert ( + data["addresses"][0]["address_line_1"] + == payload["addresses"][0]["address_line_1"] + ) + assert ( + data["addresses"][0]["address_line_2"] + == payload["addresses"][0]["address_line_2"] + ) + assert data["addresses"][0]["city"] == payload["addresses"][0]["city"] + assert data["addresses"][0]["state"] == payload["addresses"][0]["state"] + assert data["addresses"][0]["postal_code"] == payload["addresses"][0]["postal_code"] + assert data["addresses"][0]["country"] == payload["addresses"][0]["country"] + assert ( + data["addresses"][0]["address_type"] == payload["addresses"][0]["address_type"] + ) + + cleanup_post_test(Contact, data["id"]) def test_phone_validation_fail(thing): From de4403596c95456fcd3b022161f45a81939d2838 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 13:28:32 -0600 Subject: [PATCH 07/56] refactor: differentiate add contact from fixture --- tests/test_contact.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/test_contact.py b/tests/test_contact.py index c23eb38fc..15887c674 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -8,16 +8,16 @@ def test_add_contact(thing): payload = { - "name": "Test Contact", + "name": "Test Contact 2", "role": "Owner", "thing_id": thing.id, - "emails": [{"email": "fasdfasdf@gmail.com", "email_type": "Primary"}], - "phones": [{"phone_number": "+12345678901", "phone_type": "Primary"}], + "emails": [{"email": "testcontact2@gmail.com", "email_type": "Primary"}], + "phones": [{"phone_number": "+14153334444", "phone_type": "Primary"}], "addresses": [ { - "address_line_1": "123 Main St", - "address_line_2": "Apt 4B", - "city": "Test City", + "address_line_1": "123 Default St", + "address_line_2": "Apt 8R", + "city": "Test Metropolis", "state": "NM", "postal_code": "87501", "country": "United States", @@ -60,6 +60,10 @@ def test_add_contact(thing): cleanup_post_test(Contact, data["id"]) +def test_add_address(contact): + pass + + def test_phone_validation_fail(thing): for phone in [ "definitely not a phone", From 44a4989e1169630fcb879f90fb80db09784a337f Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 13:28:50 -0600 Subject: [PATCH 08/56] feat: add contact fixture --- tests/conftest.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index a0596c671..e7afd204d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -76,3 +76,32 @@ def sample(thing, sensor): yield sample session.close() + + +@pytest.fixture(scope="session") +def contact(thing): + with session_ctx() as session: + contact = Contact( + name="Test Contact", + role="Owner", + thing_id=thing.id, + emails=[{"email": "test@example.com", "email_type": "Primary"}], + phones=[{"phone_number": "+12345678901", "phone_type": "Primary"}], + addresses=[ + { + "address_line_1": "123 Main St", + "address_line_2": "Apt 4B", + "city": "Test City", + "state": "NM", + "postal_code": "87501", + "country": "United States", + "address_type": "Primary", + } + ], + ) + session.add(contact) + session.commit() + session.refresh(contact) + yield contact + + session.close() From 005b6985d7bfb065345ec2217041afe77ef06f01 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 13:50:23 -0600 Subject: [PATCH 09/56] feat: add address, phone, email fixtures --- tests/conftest.py | 71 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 57 insertions(+), 14 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index e7afd204d..ff8d081f3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -84,24 +84,67 @@ def contact(thing): contact = Contact( name="Test Contact", role="Owner", - thing_id=thing.id, - emails=[{"email": "test@example.com", "email_type": "Primary"}], - phones=[{"phone_number": "+12345678901", "phone_type": "Primary"}], - addresses=[ - { - "address_line_1": "123 Main St", - "address_line_2": "Apt 4B", - "city": "Test City", - "state": "NM", - "postal_code": "87501", - "country": "United States", - "address_type": "Primary", - } - ], ) session.add(contact) session.commit() session.refresh(contact) + + thing_contact_association = ThingContactAssociation( + thing_id=thing.id, contact_id=contact.id + ) + session.add(thing_contact_association) + session.commit() + session.refresh(thing_contact_association) + yield contact session.close() + + +@pytest.fixture(scope="session") +def address(contact): + with session_ctx() as session: + address = Address( + address_line_1="123 Main St", + address_line_2="Apt 4B", + city="Test City", + state="NM", + postal_code="87501", + country="United States", + address_type="Primary", + contact_id=contact.id, + ) + session.add(address) + session.commit() + session.refresh(address) + yield address + + session.close() + + +@pytest.fixture(scope="session") +def email(contact): + with session_ctx() as session: + email = Email( + email="test@example.com", email_type="Primary", contact_id=contact.id + ) + session.add(email) + session.commit() + session.refresh(email) + yield email + + session.close() + + +@pytest.fixture(scope="session") +def phone(contact): + with session_ctx() as session: + phone = Phone( + phone_number="505-123-4567", phone_type="Mobile", contact_id=contact.id + ) + session.add(phone) + session.commit() + session.refresh(phone) + yield phone + + session.close() From 7503eabd95eb3d27497a1b11765ea3cc449a912a Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 13:53:20 -0600 Subject: [PATCH 10/56] feat: add_address function --- services/people_helper.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/services/people_helper.py b/services/people_helper.py index 3f250ffaa..a8eee2d1b 100644 --- a/services/people_helper.py +++ b/services/people_helper.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== from db.contact import Contact, Email, Phone, Address, ThingContactAssociation -from schemas.contact import CreateContact +from schemas.contact import CreateAddress, CreateContact from sqlalchemy.orm import Session @@ -66,4 +66,23 @@ def add_contact( return contact +def add_address( + session: Session, + contact_id: int, + address_data: dict, +) -> Address: + """ + Add an address to a contact. + """ + if isinstance(address_data, CreateAddress): + address_data = address_data.model_dump(exclude_unset=True) + + address = Address(**address_data, contact_id=contact_id) + session.add(address) + session.commit() + session.refresh(address) + + return address + + # ============= EOF ============================================= From 26582a25904c0419690d3d0665b6293119cf70e8 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 13:54:49 -0600 Subject: [PATCH 11/56] feat: add POST /{contact_id}/address endpoint --- api/contact.py | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/api/contact.py b/api/contact.py index 38ddc254e..7050a32bb 100644 --- a/api/contact.py +++ b/api/contact.py @@ -13,11 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from typing import List, Annotated - -from fastapi import APIRouter, Depends, Query -from pydantic import BaseModel, Field -from pydantic_core import PydanticUndefined +from fastapi import APIRouter, Query from fastapi import APIRouter from sqlalchemy import select from starlette import status @@ -26,10 +22,10 @@ from fastapi_pagination.ext.sqlalchemy import paginate from core.dependencies import session_dependency -from db import ThingContactAssociation, Thing -from db.contact import Contact, Email, Phone, Address +from db import ThingContactAssociation, Thing, Contact, Email, Phone, Address from schemas.contact import ( CreateContact, + CreateAddress, PhoneResponse, EmailResponse, AddressResponse, @@ -40,7 +36,7 @@ UpdateAddress, ) from services.crud_helper import model_patcher -from services.people_helper import add_contact +from services.people_helper import add_contact, add_address from services.query_helper import ( simple_get_by_id, paginated_all_getter, @@ -49,6 +45,8 @@ router = APIRouter(prefix="/contact", tags=["contact"]) +# ====== POST ================================================================== + @router.post( "", @@ -61,7 +59,26 @@ def create_contact( return add_contact(session, contact_data) - # return adder(session, Contact, contact_data) + +@router.post( + "/{contact_id}/address", + summary="Add an address to a contact", + status_code=status.HTTP_201_CREATED, +) +def add_address_to_contact( + contact_id: int, + address_data: CreateAddress, + session: session_dependency, +) -> AddressResponse: + """ + Add a new address to an existing contact in the database. + :param contact_id: ID of the contact to add the address to + :param address_data: Data for the new address + :param session: Database session + :return: Response containing the added address + """ + contact = simple_get_by_id(session, Contact, contact_id) + return add_address(session, contact.id, address_data) @router.patch("/{contact_id}", summary="Update contact") From 38e101352da0df913190318315071ee3c4660922 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 13:55:47 -0600 Subject: [PATCH 12/56] feat: test adding contact addresses --- tests/test_contact.py | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/tests/test_contact.py b/tests/test_contact.py index 15887c674..4ca30f82e 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -1,4 +1,4 @@ -from db import Contact +from db import Contact, Address from tests import client, cleanup_post_test @@ -61,7 +61,45 @@ def test_add_contact(thing): def test_add_address(contact): - pass + payload = { + "address_line_1": "456 Secondary St", + "address_line_2": "Apt 12A", + "city": "Test Metropolis", + "state": "NM", + "postal_code": "87502", + "country": "United States", + "address_type": "Primary", + } + response = client.post(f"/contact/{contact.id}/address", json=payload) + data = response.json() + assert response.status_code == 201 + assert "id" in data + assert data["address_line_1"] == payload["address_line_1"] + assert data["address_line_2"] == payload["address_line_2"] + assert data["city"] == payload["city"] + assert data["state"] == payload["state"] + assert data["postal_code"] == payload["postal_code"] + assert data["country"] == payload["country"] + assert data["address_type"] == payload["address_type"] + + cleanup_post_test(Address, contact.id) + + +def test_add_address_404_contact_not_found(contact): + bad_contact_id = 9999 + payload = { + "address_line_1": "456 Secondary St", + "address_line_2": "Apt 12A", + "city": "Test Metropolis", + "state": "NM", + "postal_code": "87502", + "country": "United States", + "address_type": "Secondary", + } + response = client.post(f"/contact/{bad_contact_id}/address", json=payload) + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Contact with ID {bad_contact_id} not found." def test_phone_validation_fail(thing): From 4b6a2b3bf3c23cc04e862c76c44b696f05021087 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 14:05:24 -0600 Subject: [PATCH 13/56] feat: implement POST endpoint and test for contact email --- api/contact.py | 15 ++++++++++++++- services/people_helper.py | 21 ++++++++++++++++++++- tests/test_contact.py | 23 ++++++++++++++++++++++- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/api/contact.py b/api/contact.py index 7050a32bb..f63494595 100644 --- a/api/contact.py +++ b/api/contact.py @@ -26,6 +26,7 @@ from schemas.contact import ( CreateContact, CreateAddress, + CreateEmail, PhoneResponse, EmailResponse, AddressResponse, @@ -36,7 +37,7 @@ UpdateAddress, ) from services.crud_helper import model_patcher -from services.people_helper import add_contact, add_address +from services.people_helper import add_contact, add_address, add_email from services.query_helper import ( simple_get_by_id, paginated_all_getter, @@ -81,6 +82,18 @@ def add_address_to_contact( return add_address(session, contact.id, address_data) +@router.post( + "/{contact_id}/email", + summary="Add an email to a contact", + status_code=status.HTTP_201_CREATED, +) +def add_email_to_contact( + contact_id: int, email_data: CreateEmail, session: session_dependency +) -> EmailResponse: + contact = simple_get_by_id(session, Contact, contact_id) + return add_email(session, contact.id, email_data) + + @router.patch("/{contact_id}", summary="Update contact") def update_contact( contact_id: int, diff --git a/services/people_helper.py b/services/people_helper.py index a8eee2d1b..6eed2bf3d 100644 --- a/services/people_helper.py +++ b/services/people_helper.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== from db.contact import Contact, Email, Phone, Address, ThingContactAssociation -from schemas.contact import CreateAddress, CreateContact +from schemas.contact import CreateAddress, CreateContact, CreateEmail from sqlalchemy.orm import Session @@ -85,4 +85,23 @@ def add_address( return address +def add_email( + session: Session, + contact_id: int, + email_data: dict, +) -> Email: + """ + Add an email to a contact. + """ + if isinstance(email_data, CreateEmail): + email_data = email_data.model_dump(exclude_unset=True) + + email = Email(**email_data, contact_id=contact_id) + session.add(email) + session.commit() + session.refresh(email) + + return email + + # ============= EOF ============================================= diff --git a/tests/test_contact.py b/tests/test_contact.py index 4ca30f82e..53a0eb549 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -1,4 +1,4 @@ -from db import Contact, Address +from db import Contact, Address, Email from tests import client, cleanup_post_test @@ -102,6 +102,27 @@ def test_add_address_404_contact_not_found(contact): assert data["detail"] == f"Contact with ID {bad_contact_id} not found." +def test_add_email(contact): + payload = {"email": "anothertestemail@nmt.edu", "email_type": "Primary"} + response = client.post(f"/contact/{contact.id}/email", json=payload) + data = response.json() + assert response.status_code == 201 + assert "id" in data + assert data["email"] == payload["email"] + assert data["email_type"] == payload["email_type"] + + cleanup_post_test(Email, contact.id) + + +def test_add_email_404_contact_not_found(contact): + bad_contact_id = 9999 + payload = {"email": "anothertestemail@nmt.edu", "email_type": "Primary"} + response = client.post(f"/contact/{bad_contact_id}/email", json=payload) + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Contact with ID {bad_contact_id} not found." + + def test_phone_validation_fail(thing): for phone in [ "definitely not a phone", From 208225cf3287d427062705768dde8c0be41a7cfa Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 14:16:41 -0600 Subject: [PATCH 14/56] feat: implement POST endpoint and test for contact phone --- api/contact.py | 15 ++++++++++++++- services/people_helper.py | 21 ++++++++++++++++++++- tests/test_contact.py | 27 ++++++++++++++++++++++++--- 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/api/contact.py b/api/contact.py index f63494595..f23fe3a99 100644 --- a/api/contact.py +++ b/api/contact.py @@ -27,6 +27,7 @@ CreateContact, CreateAddress, CreateEmail, + CreatePhone, PhoneResponse, EmailResponse, AddressResponse, @@ -37,7 +38,7 @@ UpdateAddress, ) from services.crud_helper import model_patcher -from services.people_helper import add_contact, add_address, add_email +from services.people_helper import add_contact, add_address, add_email, add_phone from services.query_helper import ( simple_get_by_id, paginated_all_getter, @@ -94,6 +95,18 @@ def add_email_to_contact( return add_email(session, contact.id, email_data) +@router.post( + "/{contact_id}/phone", + summary="Add a phone number to a contact", + status_code=status.HTTP_201_CREATED, +) +def add_phone_to_contact( + contact_id: int, phone_data: CreatePhone, session: session_dependency +) -> PhoneResponse: + contact = simple_get_by_id(session, Contact, contact_id) + return add_phone(session, contact.id, phone_data) + + @router.patch("/{contact_id}", summary="Update contact") def update_contact( contact_id: int, diff --git a/services/people_helper.py b/services/people_helper.py index 6eed2bf3d..abf1ff925 100644 --- a/services/people_helper.py +++ b/services/people_helper.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== from db.contact import Contact, Email, Phone, Address, ThingContactAssociation -from schemas.contact import CreateAddress, CreateContact, CreateEmail +from schemas.contact import CreateAddress, CreateContact, CreateEmail, CreatePhone from sqlalchemy.orm import Session @@ -104,4 +104,23 @@ def add_email( return email +def add_phone( + session: Session, + contact_id: int, + phone_data: dict, +) -> Phone: + """ + Add a phone number to a contact. + """ + if isinstance(phone_data, CreatePhone): + phone_data = phone_data.model_dump(exclude_unset=True) + + phone = Phone(**phone_data, contact_id=contact_id) + session.add(phone) + session.commit() + session.refresh(phone) + + return phone + + # ============= EOF ============================================= diff --git a/tests/test_contact.py b/tests/test_contact.py index 53a0eb549..ac94f6037 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -1,4 +1,4 @@ -from db import Contact, Address, Email +from db import Contact, Address, Email, Phone from tests import client, cleanup_post_test @@ -82,7 +82,7 @@ def test_add_address(contact): assert data["country"] == payload["country"] assert data["address_type"] == payload["address_type"] - cleanup_post_test(Address, contact.id) + cleanup_post_test(Address, data["id"]) def test_add_address_404_contact_not_found(contact): @@ -111,7 +111,7 @@ def test_add_email(contact): assert data["email"] == payload["email"] assert data["email_type"] == payload["email_type"] - cleanup_post_test(Email, contact.id) + cleanup_post_test(Email, data["id"]) def test_add_email_404_contact_not_found(contact): @@ -123,6 +123,27 @@ def test_add_email_404_contact_not_found(contact): assert data["detail"] == f"Contact with ID {bad_contact_id} not found." +def test_add_phone(contact): + payload = {"phone_number": "+12345678901", "phone_type": "Primary"} + response = client.post(f"/contact/{contact.id}/phone", json=payload) + data = response.json() + assert response.status_code == 201 + assert "id" in data + assert data["phone_number"] == payload["phone_number"] + assert data["phone_type"] == payload["phone_type"] + + cleanup_post_test(Phone, data["id"]) + + +def test_add_phone_404_contact_not_found(contact): + bad_contact_id = 9999 + payload = {"phone_number": "+12345678901", "phone_type": "Primary"} + response = client.post(f"/contact/{bad_contact_id}/phone", json=payload) + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Contact with ID {bad_contact_id} not found." + + def test_phone_validation_fail(thing): for phone in [ "definitely not a phone", From e49ab42cfc7d727e565f2c484ee2c934c5ac62ee Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 14:28:46 -0600 Subject: [PATCH 15/56] feat: implement and test validators for email/phone --- schemas/contact.py | 5 ++ tests/test_contact.py | 114 +++++++++++++----------------------------- 2 files changed, 40 insertions(+), 79 deletions(-) diff --git a/schemas/contact.py b/schemas/contact.py index 4984cd2cf..479fec9c7 100644 --- a/schemas/contact.py +++ b/schemas/contact.py @@ -29,6 +29,8 @@ class ValidateEmail(BaseModel): + email: str + @field_validator("email", check_fields=False) @classmethod def validate_email(cls, email: str | None) -> str | None: @@ -42,9 +44,12 @@ def validate_email(cls, email: str | None) -> str | None: class ValidatePhone(BaseModel): + phone_number: str + @field_validator("phone_number", check_fields=False) @classmethod def validate_phone(cls, phone_number_str: str | None) -> str | None: + print(phone_number_str) if phone_number_str is not None: region = "US" try: diff --git a/tests/test_contact.py b/tests/test_contact.py index ac94f6037..db26f107e 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -1,9 +1,43 @@ from db import Contact, Address, Email, Phone from tests import client, cleanup_post_test +from schemas.contact import ValidateEmail, ValidatePhone +# VALIDATION tests ============================================================= -# ADD tests ====================================================== + +def test_validate_phone(thing): + for phone in [ + "definitely not a phone", + # "1234567890", + # "123-456-7890", + # "123-456-78901", + # "123-4567-890", + "123-456-789a", + "123-456-7890x1234", + "123.456.7890", + "(123) 456-7890", + ]: + try: + new_phone = ValidatePhone(phone_number=phone, phone_type="Primary") + except Exception as e: + assert e.errors()[0]["msg"] == f"Value error, Invalid phone number. {phone}" + + +def test_validate_email(thing): + for email in [ + "invalid-email", + "user@.com", + "user@domain..com", + "user@domain.com", + ]: + try: + new_email = ValidateEmail(email=email) + except Exception as e: + assert e.errors()[0]["msg"] == f"Value error, Invalid email format. {email}" + + +# ADD tests ==================================================================== def test_add_contact(thing): @@ -144,84 +178,6 @@ def test_add_phone_404_contact_not_found(contact): assert data["detail"] == f"Contact with ID {bad_contact_id} not found." -def test_phone_validation_fail(thing): - for phone in [ - "definitely not a phone", - # "1234567890", - # "123-456-7890", - # "123-456-78901", - # "123-4567-890", - "123-456-789a", - "123-456-7890x1234", - "123.456.7890", - "(123) 456-7890", - ]: - - response = client.post( - "/contact", - json={ - "name": "Test Contact 2", - "thing_id": thing.id, - "role": "Primary", - "emails": [{"email": "fasdfasdf@gmail.com", "email_type": "Primary"}], - "phones": [{"phone_number": phone, "phone_type": "Primary"}], - "addresses": [ - { - "address_line_1": "123 Main St", - "city": "Test City", - "state": "NM", - "postal_code": "87501", - "country": "United States", - "address_type": "Primary", - } - ], - }, - ) - data = response.json() - assert response.status_code == 422 - assert "detail" in data, "Expected 'detail' in response" - assert len(data["detail"]) == 1, "Expected 1 error in response" - detail = data["detail"][0] - assert detail["msg"] == f"Value error, Invalid phone number. {phone}" - - -def test_email_validation_fail(thing): - - for email in [ - "", - "invalid-email", - "invalid@domain", - "invalid@domain.", - "@domain.com", - ]: - response = client.post( - "/contact", - json={ - "name": "Test ContactX", - "thing_id": thing.id, - "role": "Primary", - "emails": [{"email": email, "email_type": "Primary"}], - "phones": [{"phone_number": "+12345678901", "phone_type": "Primary"}], - "addresses": [ - { - "address_line_1": "123 Main St", - "city": "Test City", - "state": "NM", - "postal_code": "87501", - "country": "United States", - "address_type": "Primary", - } - ], - }, - ) - data = response.json() - assert response.status_code == 422 - assert "detail" in data, "Expected 'detail' in response" - assert len(data["detail"]) == 1, "Expected 1 error in response" - detail = data["detail"][0] - assert detail["msg"] == f"Value error, Invalid email format. {email}" - - # GET tests ====================================================== From e638a0430b7c9735fe1d551836050006eefec948 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 14:33:34 -0600 Subject: [PATCH 16/56] fix: validated fields must be defined in validate class --- schemas/sample.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/schemas/sample.py b/schemas/sample.py index 35df8872d..a958e1771 100644 --- a/schemas/sample.py +++ b/schemas/sample.py @@ -54,6 +54,10 @@ class ValidateSample(BaseModel): # REFACTOR TODO: fields are evaluated in the order in which they are defined. # are sample top/bottom really working as expected? + sample_date: AwareDatetime | None = None + sample_top: float | None = None + sample_bottom: float | None = None + @model_validator(mode="after") def validate_top_and_bottom(self) -> Self: """ From 390da0abf127e08c61edaf4c44ed89ff80153a51 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 14:34:10 -0600 Subject: [PATCH 17/56] fix: validated fields must be defined in validate class --- schemas/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/schemas/sensor.py b/schemas/sensor.py index fd2ab7e7c..e412895a3 100644 --- a/schemas/sensor.py +++ b/schemas/sensor.py @@ -29,8 +29,8 @@ class ValidateSensor(BaseModel): - datetime_installed: AwareDatetime - datetime_removed: AwareDatetime + datetime_installed: AwareDatetime | None = None + datetime_removed: AwareDatetime | None = None @field_validator("datetime_installed", "datetime_removed") def convert_datetime_fields_to_utc(cls, field: AwareDatetime) -> AwareDatetime: From 6074697c2689f3d2ab10d9777981be132667480e Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 14:38:04 -0600 Subject: [PATCH 18/56] fix: validated fields must be defined in validate class --- schemas/contact.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/schemas/contact.py b/schemas/contact.py index 479fec9c7..e9a2d3f2e 100644 --- a/schemas/contact.py +++ b/schemas/contact.py @@ -29,7 +29,7 @@ class ValidateEmail(BaseModel): - email: str + email: str | None = None @field_validator("email", check_fields=False) @classmethod @@ -44,7 +44,7 @@ def validate_email(cls, email: str | None) -> str | None: class ValidatePhone(BaseModel): - phone_number: str + phone_number: str | None = None @field_validator("phone_number", check_fields=False) @classmethod From c3ccdeb66b4a01234f847a5710c59df2ceaf5bce Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 16:24:49 -0600 Subject: [PATCH 19/56] refactor: update test_get_contact_by_id to use fixtures --- tests/test_contact.py | 76 +++++++++++++++++++++++++++---------------- 1 file changed, 48 insertions(+), 28 deletions(-) diff --git a/tests/test_contact.py b/tests/test_contact.py index db26f107e..650659eec 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -181,37 +181,57 @@ def test_add_phone_404_contact_not_found(contact): # GET tests ====================================================== -# def test_get_locations(): -# response = client.get("/base/location") -# assert response.status_code == 200 -# assert len(response.json()) > 0 - - -def test_get_contacts(): +def test_get_contacts(contact, email, address, phone): response = client.get("/contact") assert response.status_code == 200 - data = response.json() - assert "items" in data, "Expected 'items' in response" - items = data["items"] - assert isinstance(items, list), "'items' should be a list" - assert len(items) > 0, "'items' should not be empty" - item = items[0] - assert "id" in item, "Expected 'id' in contact item" - assert "name" in item, "Expected 'name' in contact item" - assert "role" in item, "Expected 'role' in contact item" - assert "emails" in item, "Expected 'emails' in contact item" - assert "phones" in item, "Expected 'phones' in contact item" - assert "addresses" in item, "Expected 'addresses' in contact item" - assert isinstance(item["emails"], list), "'emails' should be a list" - assert isinstance(item["phones"], list), "'phones' should be a list" - assert isinstance(item["addresses"], list), "'addresses' should be a list" - assert len(item["emails"]) == 1, "'emails' should not be empty" - assert len(item["phones"]) == 1, "'phones' should not be empty" - assert len(item["addresses"]) == 1, "'addresses' should not be empty" - - # print(response.json()) - # assert len(response.json()) > 0 + assert data["total"] == 1 + assert data["items"][0]["id"] == contact.id + assert data["items"][0]["name"] == contact.name + assert data["items"][0]["role"] == contact.role + + assert len(data["items"][0]["emails"]) == 1 + assert data["items"][0]["emails"][0]["email"] == email.email + assert data["items"][0]["emails"][0]["email_type"] == email.email_type + + assert len(data["items"][0]["phones"]) == 1 + assert data["items"][0]["phones"][0]["phone_number"] == phone.phone_number + assert data["items"][0]["phones"][0]["phone_type"] == phone.phone_type + + assert len(data["items"][0]["addresses"]) == 1 + assert data["items"][0]["addresses"][0]["address_line_1"] == address.address_line_1 + assert data["items"][0]["addresses"][0]["address_line_2"] == address.address_line_2 + assert data["items"][0]["addresses"][0]["city"] == address.city + assert data["items"][0]["addresses"][0]["state"] == address.state + assert data["items"][0]["addresses"][0]["postal_code"] == address.postal_code + assert data["items"][0]["addresses"][0]["country"] == address.country + assert data["items"][0]["addresses"][0]["address_type"] == address.address_type + + +def test_get_contact_by_id(contact, email, address, phone): + response = client.get(f"/contact/{contact.id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == contact.id + assert data["name"] == contact.name + assert data["role"] == contact.role + + assert len(data["emails"]) == 1 + assert data["emails"][0]["email"] == email.email + assert data["emails"][0]["email_type"] == email.email_type + + assert len(data["phones"]) == 1 + assert data["phones"][0]["phone_number"] == phone.phone_number + assert data["phones"][0]["phone_type"] == phone.phone_type + + assert len(data["addresses"]) == 1 + assert data["addresses"][0]["address_line_1"] == address.address_line_1 + assert data["addresses"][0]["address_line_2"] == address.address_line_2 + assert data["addresses"][0]["city"] == address.city + assert data["addresses"][0]["state"] == address.state + assert data["addresses"][0]["postal_code"] == address.postal_code + assert data["addresses"][0]["country"] == address.country + assert data["addresses"][0]["address_type"] == address.address_type def test_get_email_by_contact_id(): From ce0f3daeeabae3520abf80986659b32c59898802 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 16:28:35 -0600 Subject: [PATCH 20/56] feat: test 404 get contact by id --- tests/test_contact.py | 45 ++++++++----------------------------------- 1 file changed, 8 insertions(+), 37 deletions(-) diff --git a/tests/test_contact.py b/tests/test_contact.py index 650659eec..c9282b311 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -234,6 +234,14 @@ def test_get_contact_by_id(contact, email, address, phone): assert data["addresses"][0]["address_type"] == address.address_type +def test_get_contact_by_id_404_not_found(contact): + bad_contact_id = 99999 + response = client.get(f"/contact/{bad_contact_id}") + data = response.json() + assert response.status_code == 404 + assert data["detail"] == f"Contact with ID {bad_contact_id} not found." + + def test_get_email_by_contact_id(): response = client.get("/contact/1/email") assert response.status_code == 200 @@ -280,43 +288,6 @@ def test_get_address_by_contact_id(): assert "address_type" in address, "Expected 'address_type' in address item" -# test item retrieval via filter =========================================== - - -# Test item retrieval ====================================================== -def test_item_get_contact(): - response = client.get("/contact/1") - assert response.status_code == 200 - data = response.json() - assert data["id"] == 1 - assert data["name"] == "Test Contact" - - assert "emails" in data - emails = data["emails"] - assert len(emails) == 1 - email = emails[0] - assert email["email"] == "fasdfasdf@gmail.com" - assert email["email_type"] == "Primary" - - assert "phones" in data - phones = data["phones"] - assert len(phones) == 1 - phone = phones[0] - assert phone["phone_number"] == "+12345678901" - assert phone["phone_type"] == "Primary" - - assert "addresses" in data - addresses = data["addresses"] - assert len(addresses) == 1 - address = addresses[0] - assert address["address_line_1"] == "123 Main St" - assert address["city"] == "Test City" - assert address["state"] == "NM" - assert address["postal_code"] == "87501" - assert address["country"] == "United States" - assert address["address_type"] == "Primary" - - # Test item edit ========================================================== def test_item_edit_contact_name(): response = client.patch( From f9980722200c3ab179c35f768bd51570158893fd Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 16:31:20 -0600 Subject: [PATCH 21/56] refactor: remove old error message 404 error handling is now done by simple_get_by_id --- api/contact.py | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/api/contact.py b/api/contact.py index f23fe3a99..a61528b4f 100644 --- a/api/contact.py +++ b/api/contact.py @@ -186,6 +186,9 @@ def update_contact_address( return model_patcher(session, Address, address_id, address_data) +# ====== GET =================================================================== + + @router.get("", summary="Get contacts") async def get_contacts( session: session_dependency, @@ -217,10 +220,7 @@ async def get_contact_by_id( """ Retrieve a contact by ID from the database. """ - contact = simple_get_by_id(session, Contact, contact_id) - if not contact: - return {"message": "Contact not found"} - return contact + return simple_get_by_id(session, Contact, contact_id) @router.get("/{contact_id}/email", summary="Get contact emails") @@ -231,11 +231,7 @@ async def get_contact_emails( Retrieve all emails associated with a contact. """ contact = simple_get_by_id(session, Contact, contact_id) - if not contact: - return {"message": "Contact not found"} - - sql = select(Email).where(Email.contact_id == contact_id) - + sql = select(Email).where(Email.contact_id == contact.id) return paginate(query=sql, conn=session) @@ -247,9 +243,7 @@ async def get_contact_phones( Retrieve all phone numbers associated with a contact. """ contact = simple_get_by_id(session, Contact, contact_id) - if not contact: - return {"message": "Contact not found"} - sql = select(Phone).where(Phone.contact_id == contact_id) + sql = select(Phone).where(Phone.contact_id == contact.id) return paginate(query=sql, conn=session) @@ -261,9 +255,7 @@ async def get_contact_addresses( Retrieve all addresses associated with a contact. """ contact = simple_get_by_id(session, Contact, contact_id) - if not contact: - return {"message": "Contact not found"} - sql = select(Address).where(Address.contact_id == contact_id) + sql = select(Address).where(Address.contact_id == contact.id) return paginate(query=sql, conn=session) From d1e585ec4ee5a467fd7d63d14297f3aee11778c6 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 16:49:01 -0600 Subject: [PATCH 22/56] test: implement test_get_contact_emails --- tests/test_contact.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/test_contact.py b/tests/test_contact.py index c9282b311..cdd1ea701 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -191,14 +191,17 @@ def test_get_contacts(contact, email, address, phone): assert data["items"][0]["role"] == contact.role assert len(data["items"][0]["emails"]) == 1 + assert data["items"][0]["emails"][0]["id"] == email.id assert data["items"][0]["emails"][0]["email"] == email.email assert data["items"][0]["emails"][0]["email_type"] == email.email_type assert len(data["items"][0]["phones"]) == 1 + assert data["items"][0]["phones"][0]["id"] == phone.id assert data["items"][0]["phones"][0]["phone_number"] == phone.phone_number assert data["items"][0]["phones"][0]["phone_type"] == phone.phone_type assert len(data["items"][0]["addresses"]) == 1 + assert data["items"][0]["addresses"][0]["id"] == address.id assert data["items"][0]["addresses"][0]["address_line_1"] == address.address_line_1 assert data["items"][0]["addresses"][0]["address_line_2"] == address.address_line_2 assert data["items"][0]["addresses"][0]["city"] == address.city @@ -217,14 +220,17 @@ def test_get_contact_by_id(contact, email, address, phone): assert data["role"] == contact.role assert len(data["emails"]) == 1 + assert data["emails"][0]["id"] == email.id assert data["emails"][0]["email"] == email.email assert data["emails"][0]["email_type"] == email.email_type assert len(data["phones"]) == 1 + assert data["phones"][0]["id"] == phone.id assert data["phones"][0]["phone_number"] == phone.phone_number assert data["phones"][0]["phone_type"] == phone.phone_type assert len(data["addresses"]) == 1 + assert data["addresses"][0]["id"] == address.id assert data["addresses"][0]["address_line_1"] == address.address_line_1 assert data["addresses"][0]["address_line_2"] == address.address_line_2 assert data["addresses"][0]["city"] == address.city @@ -242,18 +248,14 @@ def test_get_contact_by_id_404_not_found(contact): assert data["detail"] == f"Contact with ID {bad_contact_id} not found." -def test_get_email_by_contact_id(): - response = client.get("/contact/1/email") +def test_get_contact_emails(contact, email): + response = client.get(f"/contact/{contact.id}/email") assert response.status_code == 200 data = response.json() - assert isinstance(data, dict), "Expected a paginated response" - assert "items" in data, "Expected 'items' in response" - data = data["items"] - assert len(data) == 1, "Expected one phone number" - email = data[0] - assert "id" in email, "Expected 'id' in email item" - assert "email" in email, "Expected 'email' in email item" - assert "email_type" in email, "Expected 'email_type' in email item" + assert data["total"] == 1 + assert data["items"][0]["id"] == email.id + assert data["items"][0]["email"] == email.email + assert data["items"][0]["email_type"] == email.email_type def test_get_phone_by_contact_id(): From 9371572a45f844fed34e77028d478841d0ee64b4 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 16:52:12 -0600 Subject: [PATCH 23/56] test: test get contact items 404 contact not found --- tests/test_contact.py | 69 +++++++++++++++++++++++++++---------------- 1 file changed, 43 insertions(+), 26 deletions(-) diff --git a/tests/test_contact.py b/tests/test_contact.py index cdd1ea701..e425c578b 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -258,36 +258,53 @@ def test_get_contact_emails(contact, email): assert data["items"][0]["email_type"] == email.email_type -def test_get_phone_by_contact_id(): - response = client.get("/contact/1/phone") +def test_get_contact_emails_404_contact_not_found(contact, email): + bad_contact_id = 99999 + response = client.get(f"/contact/{bad_contact_id}/email") + data = response.json() + assert response.status_code == 404 + assert data["detail"] == f"Contact with ID {bad_contact_id} not found." + + +def test_get_contact_phones(contact, phone): + response = client.get(f"/contact/{contact.id}/phone") assert response.status_code == 200 data = response.json() - assert isinstance(data, dict), "Expected a paginated response" - assert "items" in data, "Expected 'items' in response" - data = data["items"] - assert len(data) == 1, "Expected one phone number" - phone = data[0] - assert "id" in phone, "Expected 'id' in phone item" - assert "phone_number" in phone, "Expected 'phone_number' in phone item" - assert "phone_type" in phone, "Expected 'phone_type' in phone item" - - -def test_get_address_by_contact_id(): - response = client.get("/contact/1/address") + assert data["total"] == 1 + assert data["items"][0]["id"] == phone.id + assert data["items"][0]["phone_number"] == phone.phone_number + assert data["items"][0]["phone_type"] == phone.phone_type + + +def test_get_contact_phones_404_contact_not_found(contact, phone): + bad_contact_id = 99999 + response = client.get(f"/contact/{bad_contact_id}/phone") data = response.json() + assert response.status_code == 404 + assert data["detail"] == f"Contact with ID {bad_contact_id} not found." + + +def test_get_contact_addresses(contact, address): + response = client.get(f"/contact/{contact.id}/address") assert response.status_code == 200 - assert isinstance(data, dict), "Expected a paginated response" - assert "items" in data, "Expected 'items' in response" - data = data["items"] - assert len(data) == 1, "Expected one phone number" - address = data[0] - assert "id" in address, "Expected 'id' in address item" - assert "address_line_1" in address, "Expected 'address_line_1' in address item" - assert "city" in address, "Expected 'city' in address item" - assert "state" in address, "Expected 'state' in address item" - assert "postal_code" in address, "Expected 'postal_code' in address item" - assert "country" in address, "Expected 'country' in address item" - assert "address_type" in address, "Expected 'address_type' in address item" + data = response.json() + assert data["total"] == 1 + assert data["items"][0]["id"] == address.id + assert data["items"][0]["address_line_1"] == address.address_line_1 + assert data["items"][0]["address_line_2"] == address.address_line_2 + assert data["items"][0]["city"] == address.city + assert data["items"][0]["state"] == address.state + assert data["items"][0]["postal_code"] == address.postal_code + assert data["items"][0]["country"] == address.country + assert data["items"][0]["address_type"] == address.address_type + + +def test_get_contact_addresses_404_contact_not_found(contact, address): + bad_contact_id = 99999 + response = client.get(f"/contact/{bad_contact_id}/address") + data = response.json() + assert response.status_code == 404 + assert data["detail"] == f"Contact with ID {bad_contact_id} not found." # Test item edit ========================================================== From c0eb6e1fa5f92af495f4c057aef9ca3c8f0af000 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 16:58:11 -0600 Subject: [PATCH 24/56] feat: include contact_id in contact item responses --- schemas/contact.py | 16 ++++++++++------ tests/test_contact.py | 9 +++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/schemas/contact.py b/schemas/contact.py index e9a2d3f2e..6aebdd6ce 100644 --- a/schemas/contact.py +++ b/schemas/contact.py @@ -118,32 +118,36 @@ class CreateContact(BaseModel): # -------- RESPONSE ---------- -class PhoneResponse(ORMBaseModel): + + +class BaseItemResponse(ORMBaseModel): + id: int + contact_id: int + + +class PhoneResponse(BaseItemResponse): """ Response schema for phone details. """ - id: int phone_number: str phone_type: str # e.g., 'mobile', 'landline', etc. -class EmailResponse(ORMBaseModel): +class EmailResponse(BaseItemResponse): """ Response schema for email details. """ - id: int email: str email_type: str # e.g., 'personal', 'work', etc. -class AddressResponse(ORMBaseModel): +class AddressResponse(BaseItemResponse): """ Response schema for address details. """ - id: int address_line_1: str address_line_2: str | None = None city: str diff --git a/tests/test_contact.py b/tests/test_contact.py index e425c578b..9e76b9089 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -192,16 +192,19 @@ def test_get_contacts(contact, email, address, phone): assert len(data["items"][0]["emails"]) == 1 assert data["items"][0]["emails"][0]["id"] == email.id + assert data["items"][0]["emails"][0]["contact_id"] == email.contact_id assert data["items"][0]["emails"][0]["email"] == email.email assert data["items"][0]["emails"][0]["email_type"] == email.email_type assert len(data["items"][0]["phones"]) == 1 assert data["items"][0]["phones"][0]["id"] == phone.id + assert data["items"][0]["phones"][0]["contact_id"] == phone.contact_id assert data["items"][0]["phones"][0]["phone_number"] == phone.phone_number assert data["items"][0]["phones"][0]["phone_type"] == phone.phone_type assert len(data["items"][0]["addresses"]) == 1 assert data["items"][0]["addresses"][0]["id"] == address.id + assert data["items"][0]["addresses"][0]["contact_id"] == address.contact_id assert data["items"][0]["addresses"][0]["address_line_1"] == address.address_line_1 assert data["items"][0]["addresses"][0]["address_line_2"] == address.address_line_2 assert data["items"][0]["addresses"][0]["city"] == address.city @@ -221,16 +224,19 @@ def test_get_contact_by_id(contact, email, address, phone): assert len(data["emails"]) == 1 assert data["emails"][0]["id"] == email.id + assert data["emails"][0]["contact_id"] == email.contact_id assert data["emails"][0]["email"] == email.email assert data["emails"][0]["email_type"] == email.email_type assert len(data["phones"]) == 1 assert data["phones"][0]["id"] == phone.id + assert data["phones"][0]["contact_id"] == phone.contact_id assert data["phones"][0]["phone_number"] == phone.phone_number assert data["phones"][0]["phone_type"] == phone.phone_type assert len(data["addresses"]) == 1 assert data["addresses"][0]["id"] == address.id + assert data["addresses"][0]["contact_id"] == address.contact_id assert data["addresses"][0]["address_line_1"] == address.address_line_1 assert data["addresses"][0]["address_line_2"] == address.address_line_2 assert data["addresses"][0]["city"] == address.city @@ -254,6 +260,7 @@ def test_get_contact_emails(contact, email): data = response.json() assert data["total"] == 1 assert data["items"][0]["id"] == email.id + assert data["items"][0]["contact_id"] == email.contact_id assert data["items"][0]["email"] == email.email assert data["items"][0]["email_type"] == email.email_type @@ -272,6 +279,7 @@ def test_get_contact_phones(contact, phone): data = response.json() assert data["total"] == 1 assert data["items"][0]["id"] == phone.id + assert data["items"][0]["contact_id"] == phone.contact_id assert data["items"][0]["phone_number"] == phone.phone_number assert data["items"][0]["phone_type"] == phone.phone_type @@ -290,6 +298,7 @@ def test_get_contact_addresses(contact, address): data = response.json() assert data["total"] == 1 assert data["items"][0]["id"] == address.id + assert data["items"][0]["contact_id"] == address.contact_id assert data["items"][0]["address_line_1"] == address.address_line_1 assert data["items"][0]["address_line_2"] == address.address_line_2 assert data["items"][0]["city"] == address.city From 39176893166666694cb7348494e12e1e3eda2b4e Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 17:05:19 -0600 Subject: [PATCH 25/56] feat: enable get contact items as lists and by id --- api/contact.py | 56 +++++++++++++++++++++++++++ tests/test_contact.py | 89 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/api/contact.py b/api/contact.py index a61528b4f..d2dea5886 100644 --- a/api/contact.py +++ b/api/contact.py @@ -189,6 +189,62 @@ def update_contact_address( # ====== GET =================================================================== +@router.get("/email", summary="Get all emails") +async def get_emails(session: session_dependency) -> CustomPage[EmailResponse]: + """ + Retrieve all emails from the database. + :param session: + :return: + """ + return paginated_all_getter(session, Email) + + +@router.get("/email/{email_id}", summary="Get email by ID") +async def get_email_by_id(email_id: int, session: session_dependency) -> EmailResponse: + """ + Retrieve an email by ID from the database. + """ + return simple_get_by_id(session, Email, email_id) + + +@router.get("/phone", summary="Get all phones") +async def get_phones(session: session_dependency) -> CustomPage[PhoneResponse]: + """ + Retrieve all phone numbers from the database. + :param session: + :return: + """ + return paginated_all_getter(session, Phone) + + +@router.get("/phone/{phone_id}", summary="Get phone by ID") +async def get_phone_by_id(phone_id: int, session: session_dependency) -> PhoneResponse: + """ + Retrieve a phone by ID from the database. + """ + return simple_get_by_id(session, Phone, phone_id) + + +@router.get("/address", summary="Get all addresses") +async def get_addresses(session: session_dependency) -> CustomPage[AddressResponse]: + """ + Retrieve all addresses from the database. + :param session: + :return: + """ + return paginated_all_getter(session, Address) + + +@router.get("/address/{address_id}", summary="Get address by ID") +async def get_address_by_id( + address_id: int, session: session_dependency +) -> AddressResponse: + """ + Retrieve an address by ID from the database. + """ + return simple_get_by_id(session, Address, address_id) + + @router.get("", summary="Get contacts") async def get_contacts( session: session_dependency, diff --git a/tests/test_contact.py b/tests/test_contact.py index 9e76b9089..270214bfc 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -316,6 +316,95 @@ def test_get_contact_addresses_404_contact_not_found(contact, address): assert data["detail"] == f"Contact with ID {bad_contact_id} not found." +def test_get_emails(email): + response = client.get("/contact/email") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["items"][0]["id"] == email.id + assert data["items"][0]["contact_id"] == email.contact_id + assert data["items"][0]["email"] == email.email + assert data["items"][0]["email_type"] == email.email_type + + +def test_get_email_by_id(email): + response = client.get(f"/contact/email/{email.id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == email.id + assert data["contact_id"] == email.contact_id + assert data["email"] == email.email + assert data["email_type"] == email.email_type + + +def test_get_email_404_not_found(email): + bad_email_id = 99999 + response = client.get(f"/contact/email/{bad_email_id}") + data = response.json() + assert response.status_code == 404 + assert data["detail"] == f"Email with ID {bad_email_id} not found." + + +def test_get_phones(phone): + response = client.get("/contact/phone") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["items"][0]["id"] == phone.id + assert data["items"][0]["contact_id"] == phone.contact_id + assert data["items"][0]["phone_number"] == phone.phone_number + assert data["items"][0]["phone_type"] == phone.phone_type + + +def test_get_phone_by_id(phone): + response = client.get(f"/contact/phone/{phone.id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == phone.id + assert data["contact_id"] == phone.contact_id + assert data["phone_number"] == phone.phone_number + assert data["phone_type"] == phone.phone_type + + +def test_get_addresses(address): + response = client.get("/contact/address") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["items"][0]["id"] == address.id + assert data["items"][0]["contact_id"] == address.contact_id + assert data["items"][0]["address_line_1"] == address.address_line_1 + assert data["items"][0]["address_line_2"] == address.address_line_2 + assert data["items"][0]["city"] == address.city + assert data["items"][0]["state"] == address.state + assert data["items"][0]["postal_code"] == address.postal_code + assert data["items"][0]["country"] == address.country + assert data["items"][0]["address_type"] == address.address_type + + +def test_get_address_by_id(address): + response = client.get(f"/contact/address/{address.id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == address.id + assert data["contact_id"] == address.contact_id + assert data["address_line_1"] == address.address_line_1 + assert data["address_line_2"] == address.address_line_2 + assert data["city"] == address.city + assert data["state"] == address.state + assert data["postal_code"] == address.postal_code + assert data["country"] == address.country + assert data["address_type"] == address.address_type + + +def test_get_address_by_id_404_not_found(address): + bad_address_id = 99999 + response = client.get(f"/contact/address/{bad_address_id}") + data = response.json() + assert response.status_code == 404 + assert data["detail"] == f"Address with ID {bad_address_id} not found." + + # Test item edit ========================================================== def test_item_edit_contact_name(): response = client.patch( From 8feebd9c5a6987ab5bbcbb756e7c7b1d25043128 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 17:23:48 -0600 Subject: [PATCH 26/56] feat: enable contact items to change contact id --- schemas/contact.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/schemas/contact.py b/schemas/contact.py index 6aebdd6ce..e19f5a4c0 100644 --- a/schemas/contact.py +++ b/schemas/contact.py @@ -49,7 +49,6 @@ class ValidatePhone(BaseModel): @field_validator("phone_number", check_fields=False) @classmethod def validate_phone(cls, phone_number_str: str | None) -> str | None: - print(phone_number_str) if phone_number_str is not None: region = "US" try: @@ -190,10 +189,9 @@ class UpdateEmail(ValidateEmail): Schema for updating email information. """ - # email: Annotated[Optional[str], None] - # email_type: Annotated[Optional[str], None] - email: str | None = None # None - email_type: str | None = None # None + contact_id: int | None = None + email: str | None = None + email_type: str | None = None class UpdatePhone(ValidatePhone): @@ -201,6 +199,7 @@ class UpdatePhone(ValidatePhone): Schema for updating phone information. """ + contact_id: int | None = None phone_number: str | None = None phone_type: str | None = None @@ -210,6 +209,7 @@ class UpdateAddress(BaseModel): Schema for updating address information. """ + contact_id: int | None = None address_line_1: str | None = None address_line_2: str | None = None city: str | None = None From 5482ea16e9fe780a43a31e7599ca7d325cdf15fb Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 17:26:27 -0600 Subject: [PATCH 27/56] test: implement patch contact and items tests --- tests/test_contact.py | 156 ++++++++++++++++++++++++------------------ 1 file changed, 90 insertions(+), 66 deletions(-) diff --git a/tests/test_contact.py b/tests/test_contact.py index 270214bfc..7573a2c53 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -1,6 +1,6 @@ from db import Contact, Address, Email, Phone -from tests import client, cleanup_post_test +from tests import client, cleanup_post_test, cleanup_patch_test from schemas.contact import ValidateEmail, ValidatePhone # VALIDATION tests ============================================================= @@ -405,93 +405,117 @@ def test_get_address_by_id_404_not_found(address): assert data["detail"] == f"Address with ID {bad_address_id} not found." -# Test item edit ========================================================== -def test_item_edit_contact_name(): +# PATCH tests ================================================================== + + +def test_patch_contact(contact): + payload = {"name": "Updated Contact"} response = client.patch( - "/contact/1", - json={ - "name": "Updated Contact", - }, + f"/contact/{contact.id}", + json=payload, ) assert response.status_code == 200 data = response.json() - assert data["id"] == 1 - assert data["name"] == "Updated Contact" - assert data["role"] == "Owner" + assert data["id"] == contact.id + assert data["name"] == payload["name"] - # put contact name back to original + cleanup_patch_test(Contact, payload, contact) + + +def test_patch_contact_404_not_found(contact): + bad_contact_id = 999999 + payload = {"name": "Updated Contact"} response = client.patch( - "/contact/1", - json={ - "name": "Test Contact", - }, + f"/contact/{bad_contact_id}", + json=payload, ) - assert response.status_code == 200 + + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Contact with ID {bad_contact_id} not found." -def test_edit_contact_email(): - response = client.patch("/contact/email/1", json={"email": "boo@bar.com"}) +def test_patch_email(email): + payload = {"email": "boo@bar.com"} + response = client.patch(f"/contact/email/{email.id}", json=payload) data = response.json() assert response.status_code == 200 - assert data["id"] == 1 - assert data["email"] == "boo@bar.com" - assert data["email_type"] == "Primary" + assert data["id"] == email.id + assert data["contact_id"] == email.contact_id + assert data["email"] == payload["email"] + assert data["email_type"] == email.email_type + + cleanup_patch_test(Email, payload, email) - # put contact email back to original - response = client.patch("/contact/email/1", json={"email": "fasdfasdf@gmail.com"}) + +def test_patch_email_404_not_found(email): + bad_email_id = 999999 + payload = {"email": "boo@bar.com"} + response = client.patch(f"/contact/email/{bad_email_id}", json=payload) + assert response.status_code == 404 data = response.json() - assert response.status_code == 200 - assert data["id"] == 1 - assert data["email"] == "fasdfasdf@gmail.com" + assert data["detail"] == f"Email with ID {bad_email_id} not found." -def test_edit_contact_phone(): - response = client.patch("/contact/phone/1", json={"phone_number": "+19876543210"}) +def test_patch_phone(phone): + payload = {"phone_number": "+19709654321"} + response = client.patch(f"/contact/phone/{phone.id}", json=payload) data = response.json() assert response.status_code == 200 - assert data["id"] == 1 - assert data["phone_number"] == "+19876543210" + assert data["id"] == phone.id + assert data["contact_id"] == phone.contact_id + assert data["phone_number"] == payload["phone_number"] + assert data["phone_type"] == phone.phone_type - # put contact phone back to original - response = client.patch("/contact/phone/1", json={"phone_number": "+12345678901"}) + cleanup_patch_test(Phone, payload, phone) + + +def test_patch_phone_404_not_found(phone): + bad_phone_id = 999999 + payload = {"phone_number": "+19709654321"} + response = client.patch(f"/contact/phone/{bad_phone_id}", json=payload) + assert response.status_code == 404 data = response.json() - assert response.status_code == 200 - assert data["id"] == 1 - assert data["phone_number"] == "+12345678901" + assert data["detail"] == f"Phone with ID {bad_phone_id} not found." -def test_edit_contact_address(): - response = client.patch( - "/contact/address/1", - json={ - "address_line_1": "456 Elm St", - "city": "Updated City", - "postal_code": "90210", - "country": "United States", - }, - ) +def test_edit_address(address): + payload = { + "address_line_1": "456 Elm St", + "address_line_2": "Apt 21B", + "city": "Updated City", + "state": "CA", + "postal_code": "90210", + "country": "United States", + } + response = client.patch(f"/contact/address/{address.id}", json=payload) data = response.json() assert response.status_code == 200 - assert data["id"] == 1 - assert data["address_line_1"] == "456 Elm St" - assert data["city"] == "Updated City" - assert data["state"] == "NM" - assert data["postal_code"] == "90210" - assert data["country"] == "United States" - assert data["address_type"] == "Primary" - - # put contact address back to original - response = client.patch( - "/contact/address/1", - json={ - "address_line_1": "123 Main St", - "city": "Test City", - "state": "NM", - "postal_code": "87501", - "country": "United States", - "address_type": "Primary", - }, - ) + assert data["id"] == address.id + assert data["contact_id"] == address.contact_id + assert data["address_line_1"] == payload["address_line_1"] + assert data["address_line_2"] == payload["address_line_2"] + assert data["city"] == payload["city"] + assert data["state"] == payload["state"] + assert data["postal_code"] == payload["postal_code"] + assert data["country"] == payload["country"] + assert data["address_type"] == address.address_type + + cleanup_patch_test(Address, payload, address) + + +def test_patch_address_404_not_found(address): + bad_address_id = 999999 + payload = { + "address_line_1": "456 Elm St", + "address_line_2": "Apt 21B", + "city": "Updated City", + "state": "CA", + "postal_code": "90210", + "country": "United States", + } + response = client.patch(f"/contact/address/{bad_address_id}", json=payload) data = response.json() - assert response.status_code == 200 + assert response.status_code == 404 + assert data["detail"] == f"Address with ID {bad_address_id} not found." From b41a9c3d16c55f66a3282199db25f7b36f4d9304 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 17:33:10 -0600 Subject: [PATCH 28/56] test: implement patch contact and items tests --- api/contact.py | 42 +++++++++++++++++------------------------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/api/contact.py b/api/contact.py index d2dea5886..9cba53ada 100644 --- a/api/contact.py +++ b/api/contact.py @@ -107,31 +107,7 @@ def add_phone_to_contact( return add_phone(session, contact.id, phone_data) -@router.patch("/{contact_id}", summary="Update contact") -def update_contact( - contact_id: int, - contact_data: UpdateContact, - session: session_dependency, -) -> ContactResponse: - """ - Update an existing contact in the database. - :param contact_id: ID of the contact to update - :param contact_data: Data to update the contact with - :param session: Database session - :return: Updated contact response - """ - # contact = simple_get_by_id(session, Contact, contact_id) - # if not contact: - # return {"message": "Contact not found"} - # - # for key, value in contact_data.model_dump().items(): - # setattr(contact, key, value) - # - # session.commit() - # session.refresh(contact) - - # return contact - return model_patcher(session, Contact, contact_id, contact_data) +# PATCH ======================================================================== @router.patch( @@ -186,6 +162,22 @@ def update_contact_address( return model_patcher(session, Address, address_id, address_data) +@router.patch("/{contact_id}", summary="Update contact") +def update_contact( + contact_id: int, + contact_data: UpdateContact, + session: session_dependency, +) -> ContactResponse: + """ + Update an existing contact in the database. + :param contact_id: ID of the contact to update + :param contact_data: Data to update the contact with + :param session: Database session + :return: Updated contact response + """ + return model_patcher(session, Contact, contact_id, contact_data) + + # ====== GET =================================================================== From 452b9548288944cf456e93f69e129e8c000f4a1f Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 17:44:56 -0600 Subject: [PATCH 29/56] feat: implement contact delete endpoints and tests --- api/contact.py | 37 +++++++- tests/test_contact.py | 195 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 229 insertions(+), 3 deletions(-) diff --git a/api/contact.py b/api/contact.py index 9cba53ada..31c7bf214 100644 --- a/api/contact.py +++ b/api/contact.py @@ -37,7 +37,7 @@ UpdatePhone, UpdateAddress, ) -from services.crud_helper import model_patcher +from services.crud_helper import model_patcher, model_deleter from services.people_helper import add_contact, add_address, add_email, add_phone from services.query_helper import ( simple_get_by_id, @@ -307,4 +307,39 @@ async def get_contact_addresses( return paginate(query=sql, conn=session) +# DELETE ======================================================================= + + +@router.delete("/email/{email_id}", summary="Delete contact email") +def delete_contact_email(email_id: int, session: session_dependency): + """ + Delete a contact email by ID from the database. + """ + return model_deleter(session, Email, email_id) + + +@router.delete("/phone/{phone_id}", summary="Delete contact phone") +def delete_contact_phone(phone_id: int, session: session_dependency): + """ + Delete a contact phone by ID from the database. + """ + return model_deleter(session, Phone, phone_id) + + +@router.delete("/address/{address_id}", summary="Delete contact address") +def delete_contact_address(address_id: int, session: session_dependency): + """ + Delete a contact address by ID from the database. + """ + return model_deleter(session, Address, address_id) + + +@router.delete("/{contact_id}", summary="Delete contact") +def delete_contact(contact_id: int, session: session_dependency): + """ + Delete a contact by ID from the database. + """ + return model_deleter(session, Contact, contact_id) + + # ============= EOF ============================================= diff --git a/tests/test_contact.py b/tests/test_contact.py index 7573a2c53..39dc953b3 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -1,8 +1,86 @@ -from db import Contact, Address, Email, Phone - +from db import Contact, Address, Email, Phone, ThingContactAssociation +from db.engine import session_ctx from tests import client, cleanup_post_test, cleanup_patch_test from schemas.contact import ValidateEmail, ValidatePhone +import pytest + +# ============= module & function fixtures ======================================= + + +@pytest.fixture(scope="function") +def second_contact(thing): + with session_ctx() as session: + contact = Contact( + name="Test Second Contact", + role="Owner", + ) + session.add(contact) + session.commit() + session.refresh(contact) + + thing_contact_association = ThingContactAssociation( + thing_id=thing.id, contact_id=contact.id + ) + session.add(thing_contact_association) + session.commit() + session.refresh(thing_contact_association) + + yield contact + + session.close() + + +@pytest.fixture(scope="function") +def second_email(second_contact): + with session_ctx() as session: + email = Email( + email="testsecondcontact@gmail.com", + email_type="Primary", + contact_id=second_contact.id, + ) + session.add(email) + session.commit() + session.refresh(email) + yield email + session.close() + + +@pytest.fixture(scope="function") +def second_phone(second_contact): + with session_ctx() as session: + phone = Phone( + phone_number="123-456-7890", + phone_type="Primary", + contact_id=second_contact.id, + ) + session.add(phone) + session.commit() + session.refresh(phone) + yield phone + session.close() + + +@pytest.fixture(scope="function") +def second_address(second_contact): + with session_ctx() as session: + address = Address( + address_line_1="456 Secondary St", + address_line_2="Apt 12A", + city="Test Metropolis", + state="NM", + postal_code="87501", + country="United States", + address_type="Primary", + contact_id=second_contact.id, + ) + session.add(address) + session.commit() + session.refresh(address) + yield address + session.close() + + # VALIDATION tests ============================================================= @@ -519,3 +597,116 @@ def test_patch_address_404_not_found(address): data = response.json() assert response.status_code == 404 assert data["detail"] == f"Address with ID {bad_address_id} not found." + + +# DELETE tests ================================================================= + + +def test_delete_contact(second_contact, second_email, second_phone, second_address): + response = client.delete(f"/contact/{second_contact.id}") + assert response.status_code == 204 + + # verify contact is deleted and it cascades to emails, phones, and addresses + response = client.get(f"/contact/{second_contact.id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Contact with ID {second_contact.id} not found." + + response = client.get(f"/contact/email/{second_email.id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Email with ID {second_email.id} not found." + + response = client.get(f"/contact/phone/{second_phone.id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Phone with ID {second_phone.id} not found." + + response = client.get(f"/contact/address/{second_address.id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Address with ID {second_address.id} not found." + + +def test_delete_contact_404_not_found(second_contact): + bad_contact_id = 999999 + response = client.delete(f"/contact/{bad_contact_id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Contact with ID {bad_contact_id} not found." + + +def test_delete_email(second_contact, second_email): + response = client.delete(f"/contact/email/{second_email.id}") + assert response.status_code == 204 + + # verify email is deleted + response = client.get(f"/contact/email/{second_email.id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Email with ID {second_email.id} not found." + + # verify email is no longer associated with the contact + response = client.get(f"/contact/{second_contact.id}") + assert response.status_code == 200 + data = response.json() + print(data) + assert data["emails"] == [] + + +def test_delete_email_404_not_found(second_email): + bad_email_id = 999999 + response = client.delete(f"/contact/email/{bad_email_id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Email with ID {bad_email_id} not found." + + +def test_delete_phone(second_contact, second_phone): + response = client.delete(f"/contact/phone/{second_phone.id}") + assert response.status_code == 204 + + # verify phone is deleted + response = client.get(f"/contact/phone/{second_phone.id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Phone with ID {second_phone.id} not found." + + # verify phone is no longer associated with the contact + response = client.get(f"/contact/{second_contact.id}") + assert response.status_code == 200 + data = response.json() + assert data["phones"] == [] + + +def test_delete_phone_404_not_found(second_phone): + bad_phone_id = 999999 + response = client.delete(f"/contact/phone/{bad_phone_id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Phone with ID {bad_phone_id} not found." + + +def test_delete_address(second_contact, second_address): + response = client.delete(f"/contact/address/{second_address.id}") + assert response.status_code == 204 + + # verify address is deleted + response = client.get(f"/contact/address/{second_address.id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Address with ID {second_address.id} not found." + + # verify address is no longer associated with the contact + response = client.get(f"/contact/{second_contact.id}") + assert response.status_code == 200 + data = response.json() + assert data["addresses"] == [] + + +def test_delete_address_404_not_found(second_address): + bad_address_id = 99999 + response = client.delete(f"/contact/address/{bad_address_id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Address with ID {bad_address_id} not found." From 769d0bb954651db375d744eeb7050939973baa8b Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 17:46:14 -0600 Subject: [PATCH 30/56] test: ensure contact id is returned in add tests --- tests/test_contact.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_contact.py b/tests/test_contact.py index 39dc953b3..78882ef8c 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -145,14 +145,17 @@ def test_add_contact(thing): assert data["role"] == payload["role"] assert len(data["emails"]) == 1 + assert data["emails"][0]["contact_id"] == data["id"] assert data["emails"][0]["email"] == payload["emails"][0]["email"] assert data["emails"][0]["email_type"] == payload["emails"][0]["email_type"] assert len(data["phones"]) == 1 + assert data["phones"][0]["contact_id"] == data["id"] assert data["phones"][0]["phone_number"] == payload["phones"][0]["phone_number"] assert data["phones"][0]["phone_type"] == payload["phones"][0]["phone_type"] assert len(data["addresses"]) == 1 + assert data["addresses"][0]["contact_id"] == data["id"] assert ( data["addresses"][0]["address_line_1"] == payload["addresses"][0]["address_line_1"] @@ -186,6 +189,7 @@ def test_add_address(contact): data = response.json() assert response.status_code == 201 assert "id" in data + assert data["contact_id"] == contact.id assert data["address_line_1"] == payload["address_line_1"] assert data["address_line_2"] == payload["address_line_2"] assert data["city"] == payload["city"] @@ -220,6 +224,7 @@ def test_add_email(contact): data = response.json() assert response.status_code == 201 assert "id" in data + assert data["contact_id"] == contact.id assert data["email"] == payload["email"] assert data["email_type"] == payload["email_type"] @@ -241,6 +246,7 @@ def test_add_phone(contact): data = response.json() assert response.status_code == 201 assert "id" in data + assert data["contact_id"] == contact.id assert data["phone_number"] == payload["phone_number"] assert data["phone_type"] == payload["phone_type"] From 57eee5bb9b6f865097def580b6e03d4f99eead9d Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 17:57:13 -0600 Subject: [PATCH 31/56] feat: add thing contact association to schemas --- schemas/contact.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/schemas/contact.py b/schemas/contact.py index e19f5a4c0..cb8808075 100644 --- a/schemas/contact.py +++ b/schemas/contact.py @@ -170,6 +170,16 @@ class ContactResponse(ORMBaseModel): things: List[ThingResponse] = [] # List of related things +class ThingContactAssociationResponse(ORMBaseModel): + """ + Response schema for thing-contact association details. + """ + + id: int + thing_id: int + contact_id: int + + # -------- UPDATE ---------- class UpdateContact(BaseModel): """ @@ -219,4 +229,13 @@ class UpdateAddress(BaseModel): address_type: str | None = None +class UpdatedThingContactAssociation(BaseModel): + """ + Schema for updating thing-contact association information. + """ + + thing_id: int | None = None + contact_id: int | None = None + + # ============= EOF ============================================= From 3cd96abeb56e3ccc7aa808e300455db9ea597b12 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 18:06:42 -0600 Subject: [PATCH 32/56] feat: implement GET endpoints/tests for thing-contact association --- api/contact.py | 42 ++++++++++++++++++++++++++++++++ tests/conftest.py | 20 +++++++++------- tests/test_contact.py | 56 ++++++++++++++++++++++++++++++++++++------- 3 files changed, 102 insertions(+), 16 deletions(-) diff --git a/api/contact.py b/api/contact.py index 31c7bf214..374f2c6b4 100644 --- a/api/contact.py +++ b/api/contact.py @@ -32,6 +32,7 @@ EmailResponse, AddressResponse, ContactResponse, + ThingContactAssociationResponse, UpdateContact, UpdateEmail, UpdatePhone, @@ -237,6 +238,33 @@ async def get_address_by_id( return simple_get_by_id(session, Address, address_id) +@router.get("/thing-association", summary="Get all thing-contact associations") +async def get_thing_contact_associations( + session: session_dependency, +) -> CustomPage[ThingContactAssociationResponse]: + """ + Retrieve all thing-contact associations from the database. + :param session: + :return: + """ + return paginated_all_getter(session, ThingContactAssociation) + + +@router.get( + "/thing-association/{thing_contact_association_id}", + summary="Get thing-contact association by ID", +) +async def get_thing_contact_association_by_id( + thing_contact_association_id: int, session: session_dependency +) -> ThingContactAssociationResponse: + """ + Retrieve a thing-contact association by ID from the database. + """ + return simple_get_by_id( + session, ThingContactAssociation, thing_contact_association_id + ) + + @router.get("", summary="Get contacts") async def get_contacts( session: session_dependency, @@ -307,6 +335,20 @@ async def get_contact_addresses( return paginate(query=sql, conn=session) +@router.get("/{contact_id}/thing-association", summary="Get contact's things") +async def get_contact_thing_associations( + contact_id: int, session: session_dependency +) -> CustomPage[ThingContactAssociationResponse]: + """ + Retrieve all thing-contact associations for a contact. + """ + contact = simple_get_by_id(session, Contact, contact_id) + sql = select(ThingContactAssociation).where( + ThingContactAssociation.contact_id == contact.id + ) + return paginate(query=sql, conn=session) + + # DELETE ======================================================================= diff --git a/tests/conftest.py b/tests/conftest.py index ff8d081f3..bef7cca04 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -79,7 +79,7 @@ def sample(thing, sensor): @pytest.fixture(scope="session") -def contact(thing): +def contact(): with session_ctx() as session: contact = Contact( name="Test Contact", @@ -89,18 +89,22 @@ def contact(thing): session.commit() session.refresh(contact) - thing_contact_association = ThingContactAssociation( - thing_id=thing.id, contact_id=contact.id - ) - session.add(thing_contact_association) - session.commit() - session.refresh(thing_contact_association) - yield contact session.close() +@pytest.fixture(scope="session") +def thing_contact_association(contact, thing): + with session_ctx() as session: + association = ThingContactAssociation(thing_id=thing.id, contact_id=contact.id) + session.add(association) + session.commit() + session.refresh(association) + yield association + session.close() + + @pytest.fixture(scope="session") def address(contact): with session_ctx() as session: diff --git a/tests/test_contact.py b/tests/test_contact.py index 78882ef8c..5a4e5ee5d 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -1,4 +1,4 @@ -from db import Contact, Address, Email, Phone, ThingContactAssociation +from db import Contact, Address, Email, Phone from db.engine import session_ctx from tests import client, cleanup_post_test, cleanup_patch_test from schemas.contact import ValidateEmail, ValidatePhone @@ -19,13 +19,6 @@ def second_contact(thing): session.commit() session.refresh(contact) - thing_contact_association = ThingContactAssociation( - thing_id=thing.id, contact_id=contact.id - ) - session.add(thing_contact_association) - session.commit() - session.refresh(thing_contact_association) - yield contact session.close() @@ -489,6 +482,53 @@ def test_get_address_by_id_404_not_found(address): assert data["detail"] == f"Address with ID {bad_address_id} not found." +def test_get_thing_contact_associations(thing_contact_association): + response = client.get("/contact/thing-association") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["items"][0]["id"] == thing_contact_association.id + assert data["items"][0]["contact_id"] == thing_contact_association.contact_id + assert data["items"][0]["thing_id"] == thing_contact_association.thing_id + + +def test_get_contact_thing_contact_association(contact, thing_contact_association): + response = client.get(f"/contact/{contact.id}/thing-association") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["items"][0]["id"] == thing_contact_association.id + assert data["items"][0]["contact_id"] == thing_contact_association.contact_id + assert data["items"][0]["thing_id"] == thing_contact_association.thing_id + + +def test_get_thing_contact_association_404_contact_not_found( + contact, thing_contact_association +): + bad_contact_id = 999999 + response = client.get(f"/contact/{bad_contact_id}/thing-association") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Contact with ID {bad_contact_id} not found." + + +def test_get_thing_contact_association_by_id(thing_contact_association): + response = client.get(f"/contact/thing-association/{thing_contact_association.id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == thing_contact_association.id + assert data["contact_id"] == thing_contact_association.contact_id + assert data["thing_id"] == thing_contact_association.thing_id + + +def test_get_thing_contact_association_by_id_404_not_found(thing_contact_association): + bad_id = 999999 + response = client.get(f"/contact/thing-association/{bad_id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"ThingContactAssociation with ID {bad_id} not found." + + # PATCH tests ================================================================== From 244824568a502f5176d0d2f360123334030820ec Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 18:09:35 -0600 Subject: [PATCH 33/56] fix: delete temp fixtures after yield --- tests/test_contact.py | 8 ++++++++ tests/test_sample.py | 1 + tests/test_sensor.py | 2 ++ 3 files changed, 11 insertions(+) diff --git a/tests/test_contact.py b/tests/test_contact.py index 5a4e5ee5d..69f7bbc7d 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -21,6 +21,8 @@ def second_contact(thing): yield contact + session.delete(contact) + session.commit() session.close() @@ -36,6 +38,8 @@ def second_email(second_contact): session.commit() session.refresh(email) yield email + session.delete(email) + session.commit() session.close() @@ -51,6 +55,8 @@ def second_phone(second_contact): session.commit() session.refresh(phone) yield phone + session.delete(phone) + session.commit() session.close() @@ -71,6 +77,8 @@ def second_address(second_contact): session.commit() session.refresh(address) yield address + session.delete(address) + session.commit() session.close() diff --git a/tests/test_sample.py b/tests/test_sample.py index d789ef9c2..85ae294fe 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -46,6 +46,7 @@ def second_sample(thing, sensor): yield sample session.delete(sample) session.commit() + session.close() # ============== Custom validators ================================================= diff --git a/tests/test_sensor.py b/tests/test_sensor.py index fd9a01e05..1986cb397 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -38,6 +38,8 @@ def second_sensor(): session.add(sensor) session.commit() yield sensor + session.delete(sensor) + session.commit() session.close() From 7a9d277418e0197b87cdf8cb1ffdc33c313a321b Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Thu, 14 Aug 2025 18:20:06 -0600 Subject: [PATCH 34/56] feat: implement PATCH endpoints/tests for thing-contact association --- api/contact.py | 28 ++++++++++++++++++++++++++++ schemas/contact.py | 2 +- tests/test_contact.py | 26 +++++++++++++++++++++++++- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/api/contact.py b/api/contact.py index 374f2c6b4..abde96a8d 100644 --- a/api/contact.py +++ b/api/contact.py @@ -37,6 +37,7 @@ UpdateEmail, UpdatePhone, UpdateAddress, + UpdateThingContactAssociation, ) from services.crud_helper import model_patcher, model_deleter from services.people_helper import add_contact, add_address, add_email, add_phone @@ -111,6 +112,8 @@ def add_phone_to_contact( # PATCH ======================================================================== +# TODO: catch database errors with patches, most likely foreign key constraints +# then return a 409 response @router.patch( "/email/{email_id}", ) @@ -163,6 +166,31 @@ def update_contact_address( return model_patcher(session, Address, address_id, address_data) +@router.patch( + "/thing-association/{thing_contact_association_id}", + summary="Update thing-contact association", +) +def update_thing_contact_association( + thing_contact_association_id: int, + thing_contact_association_data: UpdateThingContactAssociation, + session: session_dependency, +) -> ThingContactAssociationResponse: + """ + Update an existing thing-contact association in the database. + + :param thing_contact_association_id: + :param thing_contact_association_data: + :param session: + :return: + """ + return model_patcher( + session, + ThingContactAssociation, + thing_contact_association_id, + thing_contact_association_data, + ) + + @router.patch("/{contact_id}", summary="Update contact") def update_contact( contact_id: int, diff --git a/schemas/contact.py b/schemas/contact.py index cb8808075..0844148fa 100644 --- a/schemas/contact.py +++ b/schemas/contact.py @@ -229,7 +229,7 @@ class UpdateAddress(BaseModel): address_type: str | None = None -class UpdatedThingContactAssociation(BaseModel): +class UpdateThingContactAssociation(BaseModel): """ Schema for updating thing-contact association information. """ diff --git a/tests/test_contact.py b/tests/test_contact.py index 69f7bbc7d..090f4a3a3 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -1,4 +1,4 @@ -from db import Contact, Address, Email, Phone +from db import Contact, Address, Email, Phone, ThingContactAssociation from db.engine import session_ctx from tests import client, cleanup_post_test, cleanup_patch_test from schemas.contact import ValidateEmail, ValidatePhone @@ -653,6 +653,30 @@ def test_patch_address_404_not_found(address): assert data["detail"] == f"Address with ID {bad_address_id} not found." +def test_patch_thing_contact_association(thing_contact_association, second_contact): + payload = {"contact_id": second_contact.id} + response = client.patch( + f"/contact/thing-association/{thing_contact_association.id}", json=payload + ) + data = response.json() + assert response.status_code == 200 + assert data["id"] == thing_contact_association.id + assert data["contact_id"] == payload["contact_id"] + + cleanup_patch_test(ThingContactAssociation, payload, thing_contact_association) + + +def test_patch_thing_contact_association_404_not_found( + thing_contact_association, second_contact +): + bad_id = 999999 + payload = {"contact_id": second_contact.id} + response = client.patch(f"/contact/thing-association/{bad_id}", json=payload) + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"ThingContactAssociation with ID {bad_id} not found." + + # DELETE tests ================================================================= From c29b3f0c2943a5b1300d0b71fab556bae600fe43 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Fri, 15 Aug 2025 09:07:33 -0600 Subject: [PATCH 35/56] refactor: remove artifact from merge --- schemas/sample.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/schemas/sample.py b/schemas/sample.py index 47fa25d71..cf850abb8 100644 --- a/schemas/sample.py +++ b/schemas/sample.py @@ -51,9 +51,6 @@ class ValidateSample(BaseModel): # ) # return sample_bottom - sample_top: float | None = None - sample_bottom: float | None = None - sample_date: AwareDatetime | None = None sample_top: float | None = None sample_bottom: float | None = None From 394c2bea85f7a1b446a10ebaab9e10a0ab1ecae0 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Fri, 15 Aug 2025 10:49:16 -0600 Subject: [PATCH 36/56] refactor: use pytest raises to test exceptions --- tests/test_contact.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/tests/test_contact.py b/tests/test_contact.py index 090f4a3a3..ed120294e 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -4,6 +4,8 @@ from schemas.contact import ValidateEmail, ValidatePhone import pytest +from pydantic import ValidationError +import re # ============= module & function fixtures ======================================= @@ -85,35 +87,28 @@ def second_address(second_contact): # VALIDATION tests ============================================================= -def test_validate_phone(thing): +def test_validate_phone(): for phone in [ "definitely not a phone", - # "1234567890", - # "123-456-7890", - # "123-456-78901", - # "123-4567-890", "123-456-789a", "123-456-7890x1234", "123.456.7890", "(123) 456-7890", ]: - try: - new_phone = ValidatePhone(phone_number=phone, phone_type="Primary") - except Exception as e: - assert e.errors()[0]["msg"] == f"Value error, Invalid phone number. {phone}" + pattern = re.escape(f"Value error, Invalid phone number. {phone}") + with pytest.raises(ValidationError, match=pattern): + ValidatePhone(phone_number=phone, phone_type="Primary") -def test_validate_email(thing): +def test_validate_email(): for email in [ "invalid-email", "user@.com", "user@domain..com", - "user@domain.com", ]: - try: - new_email = ValidateEmail(email=email) - except Exception as e: - assert e.errors()[0]["msg"] == f"Value error, Invalid email format. {email}" + pattern = re.escape(f"Value error, Invalid email format. {email}") + with pytest.raises(ValidationError, match=pattern): + ValidateEmail(email=email) # ADD tests ==================================================================== From 3964f6993f5b40f6db2a75ef04496968b12f5ce6 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Fri, 15 Aug 2025 11:38:53 -0600 Subject: [PATCH 37/56] feat: enable POST of thing association with a contact --- api/contact.py | 53 +++++++++++++++++++++++++++++++++++++-- schemas/contact.py | 8 ++++++ services/people_helper.py | 24 +++++++++++++++++- tests/test_contact.py | 36 +++++++++++++++++++++++++- 4 files changed, 117 insertions(+), 4 deletions(-) diff --git a/api/contact.py b/api/contact.py index abde96a8d..2b2285085 100644 --- a/api/contact.py +++ b/api/contact.py @@ -17,7 +17,7 @@ from fastapi import APIRouter from sqlalchemy import select from starlette import status - +from sqlalchemy.exc import IntegrityError, ProgrammingError from api.pagination import CustomPage from fastapi_pagination.ext.sqlalchemy import paginate @@ -28,6 +28,7 @@ CreateAddress, CreateEmail, CreatePhone, + CreateThingAssociation, PhoneResponse, EmailResponse, AddressResponse, @@ -40,15 +41,46 @@ UpdateThingContactAssociation, ) from services.crud_helper import model_patcher, model_deleter -from services.people_helper import add_contact, add_address, add_email, add_phone +from services.people_helper import ( + add_contact, + add_address, + add_email, + add_phone, + add_thing_association, +) from services.query_helper import ( simple_get_by_id, paginated_all_getter, order_sort_filter, ) +from services.exceptions_helper import PydanticStyleException router = APIRouter(prefix="/contact", tags=["contact"]) +# ====== DB ERROR HANDLERS ===================================================== + + +def database_error_handler( + payload: CreateThingAssociation, error: IntegrityError | ProgrammingError +) -> None: + """ + Handle errors raised by the database when adding or updating a sample. + """ + error_message = error.orig.args[0]["M"] + if ( + error_message + == 'insert or update on table "thing_contact_association" violates foreign key constraint "thing_contact_association_thing_id_fkey"' + ): + loc = ["body", "thing_id"] + msg = f"Thing with ID {payload.thing_id} not found." + type_ = "value_error" + input_ = payload.thing_id + + raise PydanticStyleException( + status_code=status.HTTP_409_CONFLICT, loc=loc, msg=msg, type=type_, input=input_ + ) + + # ====== POST ================================================================== @@ -109,6 +141,23 @@ def add_phone_to_contact( return add_phone(session, contact.id, phone_data) +@router.post( + "/{contact_id}/thing-association", + summary="Add a thing-contact association to a contact", + status_code=status.HTTP_201_CREATED, +) +def add_thing_association_to_contact( + contact_id: int, + thing_association_data: CreateThingAssociation, + session: session_dependency, +) -> ThingContactAssociationResponse: + contact = simple_get_by_id(session, Contact, contact_id) + try: + return add_thing_association(session, contact.id, thing_association_data) + except ProgrammingError as e: + database_error_handler(thing_association_data, e) + + # PATCH ======================================================================== diff --git a/schemas/contact.py b/schemas/contact.py index 0844148fa..414d81f6f 100644 --- a/schemas/contact.py +++ b/schemas/contact.py @@ -99,6 +99,14 @@ class CreateAddress(BaseModel): address_type: str = "Primary" +class CreateThingAssociation(BaseModel): + """ + Schema for creating a ContactThingAssociation + """ + + thing_id: int + + class CreateContact(BaseModel): """ Schema for creating a contact. diff --git a/services/people_helper.py b/services/people_helper.py index abf1ff925..f5d542f6c 100644 --- a/services/people_helper.py +++ b/services/people_helper.py @@ -14,7 +14,13 @@ # limitations under the License. # =============================================================================== from db.contact import Contact, Email, Phone, Address, ThingContactAssociation -from schemas.contact import CreateAddress, CreateContact, CreateEmail, CreatePhone +from schemas.contact import ( + CreateAddress, + CreateContact, + CreateEmail, + CreatePhone, + CreateThingAssociation, +) from sqlalchemy.orm import Session @@ -123,4 +129,20 @@ def add_phone( return phone +def add_thing_association( + session: Session, contact_id: int, thing_association_data: dict +): + if isinstance(thing_association_data, CreateThingAssociation): + thing_association_data = thing_association_data.model_dump(exclude_unset=True) + + thing_association = ThingContactAssociation( + **thing_association_data, contact_id=contact_id + ) + session.add(thing_association) + session.commit() + session.refresh(thing_association) + + return thing_association + + # ============= EOF ============================================= diff --git a/tests/test_contact.py b/tests/test_contact.py index ed120294e..0b2a40b08 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -258,6 +258,41 @@ def test_add_phone_404_contact_not_found(contact): assert data["detail"] == f"Contact with ID {bad_contact_id} not found." +def test_add_thing_association(thing, second_contact): + payload = {"thing_id": thing.id} + response = client.post( + f"/contact/{second_contact.id}/thing-association", json=payload + ) + data = response.json() + assert response.status_code == 201 + assert "id" in data + assert data["thing_id"] == thing.id + assert data["contact_id"] == second_contact.id + + cleanup_post_test(ThingContactAssociation, data["id"]) + + +def test_add_thing_association_404_contact_not_found(contact, thing): + bad_contact_id = 99999 + payload = {"thing_id": thing.id} + response = client.post(f"/contact/{bad_contact_id}/thing-association", json=payload) + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Contact with ID {bad_contact_id} not found." + + +def test_add_thing_association_409_thing_not_found(thing, contact): + bad_thing_id = 9999 + payload = {"thing_id": bad_thing_id} + response = client.post(f"/contact/{contact.id}/thing-association", json=payload) + assert response.status_code == 409 + data = response.json() + assert data["detail"][0]["msg"] == f"Thing with ID {bad_thing_id} not found." + assert data["detail"][0]["loc"] == ["body", "thing_id"] + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == bad_thing_id + + # GET tests ====================================================== @@ -723,7 +758,6 @@ def test_delete_email(second_contact, second_email): response = client.get(f"/contact/{second_contact.id}") assert response.status_code == 200 data = response.json() - print(data) assert data["emails"] == [] From ea1b5dafb7dc6c39dbb1a07ddc193e030bed9dee Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Fri, 15 Aug 2025 12:00:29 -0600 Subject: [PATCH 38/56] refactor: refactor PydanticStyleException to enable a list of details --- api/contact.py | 34 ++++++++++++++++++++++------------ services/exceptions_helper.py | 22 +++++++++++++++++----- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/api/contact.py b/api/contact.py index 2b2285085..c8a579871 100644 --- a/api/contact.py +++ b/api/contact.py @@ -66,18 +66,25 @@ def database_error_handler( """ Handle errors raised by the database when adding or updating a sample. """ - error_message = error.orig.args[0]["M"] + detail_list = [] + + for e in error.orig.args: + error_message = e["M"] + if ( error_message == 'insert or update on table "thing_contact_association" violates foreign key constraint "thing_contact_association_thing_id_fkey"' ): - loc = ["body", "thing_id"] - msg = f"Thing with ID {payload.thing_id} not found." - type_ = "value_error" - input_ = payload.thing_id + detail = { + "loc": ["body", "thing_id"], + "msg": f"Thing with ID {payload.thing_id} not found.", + "type": "value_error", + "input": payload.thing_id, + } + detail_list.append(detail) raise PydanticStyleException( - status_code=status.HTTP_409_CONFLICT, loc=loc, msg=msg, type=type_, input=input_ + status_code=status.HTTP_409_CONFLICT, detail=detail_list ) @@ -232,12 +239,15 @@ def update_thing_contact_association( :param session: :return: """ - return model_patcher( - session, - ThingContactAssociation, - thing_contact_association_id, - thing_contact_association_data, - ) + try: + return model_patcher( + session, + ThingContactAssociation, + thing_contact_association_id, + thing_contact_association_data, + ) + except ProgrammingError as e: + database_error_handler(thing_contact_association_data, e) @router.patch("/{contact_id}", summary="Update contact") diff --git a/services/exceptions_helper.py b/services/exceptions_helper.py index 742f5e2fc..bd83a0263 100644 --- a/services/exceptions_helper.py +++ b/services/exceptions_helper.py @@ -1,4 +1,12 @@ from fastapi import HTTPException +from typing import TypedDict + + +class PydanticDetailDict(TypedDict): + loc: list + msg: str + type: str + input: dict class PydanticStyleException(HTTPException): @@ -10,12 +18,16 @@ class PydanticStyleException(HTTPException): def __init__( self, status_code: int, - loc: list, - msg: str, - type: str, - input: dict, + detail: list[PydanticDetailDict], ): + """ + The detail needs to be a list with the following keys: + "loc": list + "msg": string + "type": string + "input": Any + """ super().__init__( status_code=status_code, - detail=[{"loc": loc, "msg": msg, "type": type, "input": input}], + detail=detail, ) From 3c4eb700862be9b578be65c944cd54fd48d218b2 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Fri, 15 Aug 2025 14:40:54 -0600 Subject: [PATCH 39/56] fix: rollback if theres an error in creating a related record --- services/people_helper.py | 74 +++++++++++++++++++++++---------------- 1 file changed, 43 insertions(+), 31 deletions(-) diff --git a/services/people_helper.py b/services/people_helper.py index f5d542f6c..7901eebc8 100644 --- a/services/people_helper.py +++ b/services/people_helper.py @@ -35,39 +35,51 @@ def add_contact( if isinstance(contact_data, CreateContact): contact_data = contact_data.model_dump(exclude_unset=True) - contact = Contact( - name=contact_data["name"], - role=contact_data["role"], - ) - for e in contact_data.get("emails", []): - email = Email(**e) - contact.emails.append(email) - # session.add(email) - - for p in contact_data.get("phones", []): - phone = Phone(**p) - contact.phones.append(phone) - # session.add(phone) - - for a in contact_data.get("addresses", []): - address = Address(**a) - contact.addresses.append(address) - # session.add(address) - - session.add(contact) - session.commit() - session.refresh(contact) + """ + Developer's note - location_contact_association = ThingContactAssociation() - location_contact_association.thing_id = contact_data.get("thing_id") - location_contact_association.contact_id = contact.id + Rollback if there's an error creating a record in one of the tables so + that orphaned/fractured records are not made + """ - session.add(location_contact_association) - # owner_contact_association = OwnerContactAssociation() - # owner_contact_association.owner_id = owner.id - # owner_contact_association.contact_id = contact.id - # session.add(owner_contact_association) - session.commit() + try: + contact = Contact( + name=contact_data["name"], + role=contact_data["role"], + ) + for e in contact_data.get("emails", []): + email = Email(**e) + contact.emails.append(email) + # session.add(email) + + for p in contact_data.get("phones", []): + phone = Phone(**p) + contact.phones.append(phone) + # session.add(phone) + + for a in contact_data.get("addresses", []): + address = Address(**a) + contact.addresses.append(address) + # session.add(address) + + session.add(contact) + session.flush() + session.refresh(contact) + + location_contact_association = ThingContactAssociation() + location_contact_association.thing_id = contact_data.get("thing_id") + location_contact_association.contact_id = contact.id + + session.add(location_contact_association) + # owner_contact_association = OwnerContactAssociation() + # owner_contact_association.owner_id = owner.id + # owner_contact_association.contact_id = contact.id + # session.add(owner_contact_association) + session.flush() + session.commit() + except Exception as e: + session.rollback() + raise e return contact From e5e7983e4774d6ed7d681f3f90f698efbb9a9eb8 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Fri, 15 Aug 2025 14:42:00 -0600 Subject: [PATCH 40/56] feat: catch db errors and raise 409 error --- api/contact.py | 30 +++++++++++++++--------- tests/test_contact.py | 54 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/api/contact.py b/api/contact.py index c8a579871..4e4bd830b 100644 --- a/api/contact.py +++ b/api/contact.py @@ -17,7 +17,7 @@ from fastapi import APIRouter from sqlalchemy import select from starlette import status -from sqlalchemy.exc import IntegrityError, ProgrammingError +from sqlalchemy.exc import ProgrammingError from api.pagination import CustomPage from fastapi_pagination.ext.sqlalchemy import paginate @@ -61,15 +61,13 @@ def database_error_handler( - payload: CreateThingAssociation, error: IntegrityError | ProgrammingError + payload: CreateThingAssociation, error: ProgrammingError ) -> None: """ Handle errors raised by the database when adding or updating a sample. """ - detail_list = [] - for e in error.orig.args: - error_message = e["M"] + error_message = error.orig.args[0]["M"] if ( error_message @@ -81,11 +79,19 @@ def database_error_handler( "type": "value_error", "input": payload.thing_id, } - detail_list.append(detail) - raise PydanticStyleException( - status_code=status.HTTP_409_CONFLICT, detail=detail_list - ) + elif ( + error_message + == 'insert or update on table "thing_contact_association" violates foreign key constraint "thing_contact_association_contact_id_fkey"' + ): + detail = { + "loc": ["body", "contact_id"], + "msg": f"Contact with ID {payload.contact_id} not found.", + "type": "value_error", + "input": payload.contact_id, + } + + raise PydanticStyleException(status_code=status.HTTP_409_CONFLICT, detail=[detail]) # ====== POST ================================================================== @@ -99,8 +105,10 @@ def database_error_handler( def create_contact( contact_data: CreateContact, session: session_dependency ) -> ContactResponse: - - return add_contact(session, contact_data) + try: + return add_contact(session, contact_data) + except ProgrammingError as e: + database_error_handler(contact_data, e) @router.post( diff --git a/tests/test_contact.py b/tests/test_contact.py index 0b2a40b08..91e9d391e 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -11,7 +11,7 @@ @pytest.fixture(scope="function") -def second_contact(thing): +def second_contact(): with session_ctx() as session: contact = Contact( name="Test Second Contact", @@ -171,6 +171,32 @@ def test_add_contact(thing): cleanup_post_test(Contact, data["id"]) +def test_add_contact_409_bad_thing_id(): + bad_thing_id = 9999 + payload = { + "name": "Test Contact 3", + "role": "Owner", + "thing_id": bad_thing_id, + "emails": [{"email": "testcontact3@gmail.com", "email_type": "Primary"}], + "phones": [{"phone_number": "+14153334445", "phone_type": "Primary"}], + "addresses": [ + { + "address_line_1": "123 Default St", + "address_line_2": "Apt 8R", + "city": "Test Metropolis", + "state": "NM", + "postal_code": "87501", + "country": "United States", + "address_type": "Primary", + } + ], + } + response = client.post("/contact", json=payload) + assert response.status_code == 409 + data = response.json() + assert data["detail"][0]["msg"] == f"Thing with ID {bad_thing_id} not found." + + def test_add_address(contact): payload = { "address_line_1": "456 Secondary St", @@ -707,6 +733,32 @@ def test_patch_thing_contact_association_404_not_found( assert data["detail"] == f"ThingContactAssociation with ID {bad_id} not found." +def test_patch_thing_contact_association_409_contact_not_found( + thing_contact_association, +): + bad_contact_id = 999999 + payload = {"contact_id": bad_contact_id} + response = client.patch( + f"/contact/thing-association/{thing_contact_association.id}", json=payload + ) + assert response.status_code == 409 + data = response.json() + assert len(data["detail"]) == 1 + assert data["detail"][0]["msg"] == f"Contact with ID {bad_contact_id} not found." + + +def test_patch_thing_contact_association_409_thing_not_found(thing_contact_association): + bad_thing_id = 999999 + payload = {"thing_id": bad_thing_id} + response = client.patch( + f"/contact/thing-association/{thing_contact_association.id}", json=payload + ) + assert response.status_code == 409 + data = response.json() + assert len(data["detail"]) == 1 + assert data["detail"][0]["msg"] == f"Thing with ID {bad_thing_id} not found." + + # DELETE tests ================================================================= From 6ab812fb0f4093bf184dff20804be7b1db93a163 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Fri, 15 Aug 2025 14:53:52 -0600 Subject: [PATCH 41/56] feat: enable DELETE of thing contact association --- api/contact.py | 13 +++++++++++++ tests/test_contact.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/api/contact.py b/api/contact.py index 4e4bd830b..78b3f4f5f 100644 --- a/api/contact.py +++ b/api/contact.py @@ -471,6 +471,19 @@ def delete_contact_address(address_id: int, session: session_dependency): return model_deleter(session, Address, address_id) +@router.delete( + "/thing-association/{thing_contact_association_id}", + summary="Delete contact thing association", +) +def delete_contact_thing_association( + thing_contact_association_id: int, session: session_dependency +): + """ + Delete a contact's thing association from the database + """ + return model_deleter(session, ThingContactAssociation, thing_contact_association_id) + + @router.delete("/{contact_id}", summary="Delete contact") def delete_contact(contact_id: int, session: session_dependency): """ diff --git a/tests/test_contact.py b/tests/test_contact.py index 91e9d391e..89312c341 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -84,6 +84,21 @@ def second_address(second_contact): session.close() +@pytest.fixture(scope="function") +def second_thing_contact_association(thing, second_contact): + with session_ctx() as session: + association = ThingContactAssociation( + thing_id=thing.id, contact_id=second_contact.id + ) + session.add(association) + session.commit() + session.refresh(association) + yield association + session.delete(association) + session.commit() + session.close() + + # VALIDATION tests ============================================================= @@ -869,3 +884,31 @@ def test_delete_address_404_not_found(second_address): assert response.status_code == 404 data = response.json() assert data["detail"] == f"Address with ID {bad_address_id} not found." + + +def test_delete_thing_contact_association(second_thing_contact_association): + response = client.delete( + f"/contact/thing-association/{second_thing_contact_association.id}" + ) + assert response.status_code == 204 + + # verify association is deleted + response = client.get( + f"/contact/thing-association/{second_thing_contact_association.id}" + ) + assert response.status_code == 404 + data = response.json() + assert ( + data["detail"] + == f"ThingContactAssociation with ID {second_thing_contact_association.id} not found." + ) + + +def test_delete_thing_contact_association_404_not_found( + second_thing_contact_association, +): + bad_id = 999999 + response = client.delete(f"/contact/thing-association/{bad_id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"ThingContactAssociation with ID {bad_id} not found." From a800ac4e3c6cd0868e3e18a111571794f07b82d9 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Fri, 15 Aug 2025 15:04:26 -0600 Subject: [PATCH 42/56] refactor: update 409 db errors --- api/contact.py | 4 ++-- api/sample.py | 23 ++++++++++++++++------- tests/test_contact.py | 2 +- tests/test_sample.py | 26 ++++++++++++++++++++++---- 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/api/contact.py b/api/contact.py index 78b3f4f5f..bfc731965 100644 --- a/api/contact.py +++ b/api/contact.py @@ -77,7 +77,7 @@ def database_error_handler( "loc": ["body", "thing_id"], "msg": f"Thing with ID {payload.thing_id} not found.", "type": "value_error", - "input": payload.thing_id, + "input": {"thing_id": payload.thing_id}, } elif ( @@ -88,7 +88,7 @@ def database_error_handler( "loc": ["body", "contact_id"], "msg": f"Contact with ID {payload.contact_id} not found.", "type": "value_error", - "input": payload.contact_id, + "input": {"contact_id": payload.contact_id}, } raise PydanticStyleException(status_code=status.HTTP_409_CONFLICT, detail=[detail]) diff --git a/api/sample.py b/api/sample.py index cfb5aad7c..9a3755093 100644 --- a/api/sample.py +++ b/api/sample.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== -from fastapi import APIRouter, Depends, Query, HTTPException, Response +from fastapi import APIRouter, Depends, Query, Response from sqlalchemy.exc import IntegrityError, ProgrammingError from sqlalchemy.orm import Session from starlette.status import HTTP_201_CREATED, HTTP_409_CONFLICT @@ -28,6 +28,7 @@ from schemas.sample import SampleResponse, CreateSample, UpdateSample from services.query_helper import paginated_all_getter, simple_get_by_id from services.crud_helper import model_patcher, model_deleter +from services.exceptions_helper import PydanticStyleException router = APIRouter( prefix="/sample", @@ -46,16 +47,24 @@ def database_error_handler( error_message == 'duplicate key value violates unique constraint "sample_field_sample_id_key"' ): - detail = ( - f"Sample with field_sample_id {payload.field_sample_id} already exists." - ) + detail = { + "loc": ["body", "field_sample_id"], + "msg": f"Sample with field_sample_id {payload.field_sample_id} already exists.", + "type": "value_error", + "input": {"field_sample_id": payload.field_sample_id}, + } elif ( error_message == 'insert or update on table "sample" violates foreign key constraint "sample_thing_id_fkey"' ): - detail = f"Thing with ID {payload.thing_id} does not exist." - - raise HTTPException(status_code=HTTP_409_CONFLICT, detail=detail) + detail = { + "loc": ["body", "thing_id"], + "msg": f"Thing with ID {payload.thing_id} does not exist.", + "type": "value_error", + "input": {"thing_id": payload.thing_id}, + } + + raise PydanticStyleException(status_code=HTTP_409_CONFLICT, detail=[detail]) # ============= Post ============================================= diff --git a/tests/test_contact.py b/tests/test_contact.py index 89312c341..14d585caf 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -331,7 +331,7 @@ def test_add_thing_association_409_thing_not_found(thing, contact): assert data["detail"][0]["msg"] == f"Thing with ID {bad_thing_id} not found." assert data["detail"][0]["loc"] == ["body", "thing_id"] assert data["detail"][0]["type"] == "value_error" - assert data["detail"][0]["input"] == bad_thing_id + assert data["detail"][0]["input"] == {"thing_id": bad_thing_id} # GET tests ====================================================== diff --git a/tests/test_sample.py b/tests/test_sample.py index 5a4bdea81..6004a60ed 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -135,10 +135,13 @@ def test_409_add_sample_invalid_field_sample_id(sample, thing): ) data = response.json() assert response.status_code == 409 + assert data["detail"][0]["loc"] == ["body", "field_sample_id"] assert ( - data["detail"] + data["detail"][0]["msg"] == f"Sample with field_sample_id {sample.field_sample_id} already exists." ) + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"field_sample_id": sample.field_sample_id} def test_409_add_sample_invalid_thing_id(): @@ -166,7 +169,13 @@ def test_409_add_sample_invalid_thing_id(): ) data = response.json() assert response.status_code == 409 - assert data["detail"] == f"Thing with ID {payload['thing_id']} does not exist." + assert data["detail"][0]["loc"] == ["body", "thing_id"] + assert ( + data["detail"][0]["msg"] + == f"Thing with ID {payload['thing_id']} does not exist." + ) + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"thing_id": payload["thing_id"]} # ============= Patch tests for samples ============================================= @@ -231,10 +240,13 @@ def test_409_patch_sample_invalid_field_sample_id(sample, second_sample): ) data = response.json() assert response.status_code == 409 + assert data["detail"][0]["loc"] == ["body", "field_sample_id"] assert ( - data["detail"] + data["detail"][0]["msg"] == f"Sample with field_sample_id {payload['field_sample_id']} already exists." ) + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"field_sample_id": sample.field_sample_id} def test_409_patch_sample_invalid_thing_id(sample): @@ -250,7 +262,13 @@ def test_409_patch_sample_invalid_thing_id(sample): ) data = response.json() assert response.status_code == 409 - assert data["detail"] == f"Thing with ID {payload['thing_id']} does not exist." + assert data["detail"][0]["loc"] == ["body", "thing_id"] + assert ( + data["detail"][0]["msg"] + == f"Thing with ID {payload['thing_id']} does not exist." + ) + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"thing_id": payload["thing_id"]} # ============= Get tests for samples ============================================= From 61d4a3b8bc92a0c670d0d2cca6ee226835e919c8 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Fri, 15 Aug 2025 15:52:05 -0600 Subject: [PATCH 43/56] WIP: implement contact auth and rename people helper contact helper --- api/contact.py | 108 ++++++++++++------ .../{people_helper.py => contact_helper.py} | 0 tests/test_contact.py | 29 +++++ 3 files changed, 105 insertions(+), 32 deletions(-) rename services/{people_helper.py => contact_helper.py} (100%) diff --git a/api/contact.py b/api/contact.py index bfc731965..8f63bfc45 100644 --- a/api/contact.py +++ b/api/contact.py @@ -21,7 +21,12 @@ from api.pagination import CustomPage from fastapi_pagination.ext.sqlalchemy import paginate -from core.dependencies import session_dependency +from core.dependencies import ( + session_dependency, + amp_admin_dependency, + amp_editor_dependency, + amp_viewer_dependency, +) from db import ThingContactAssociation, Thing, Contact, Email, Phone, Address from schemas.contact import ( CreateContact, @@ -41,7 +46,7 @@ UpdateThingContactAssociation, ) from services.crud_helper import model_patcher, model_deleter -from services.people_helper import ( +from services.contact_helper import ( add_contact, add_address, add_email, @@ -103,10 +108,10 @@ def database_error_handler( status_code=status.HTTP_201_CREATED, ) def create_contact( - contact_data: CreateContact, session: session_dependency + contact_data: CreateContact, session: session_dependency, user: amp_admin_dependency ) -> ContactResponse: try: - return add_contact(session, contact_data) + return add_contact(session, contact_data, user=user) except ProgrammingError as e: database_error_handler(contact_data, e) @@ -120,6 +125,7 @@ def add_address_to_contact( contact_id: int, address_data: CreateAddress, session: session_dependency, + user: amp_admin_dependency, ) -> AddressResponse: """ Add a new address to an existing contact in the database. @@ -129,7 +135,7 @@ def add_address_to_contact( :return: Response containing the added address """ contact = simple_get_by_id(session, Contact, contact_id) - return add_address(session, contact.id, address_data) + return add_address(session, contact.id, address_data, user=user) @router.post( @@ -138,10 +144,13 @@ def add_address_to_contact( status_code=status.HTTP_201_CREATED, ) def add_email_to_contact( - contact_id: int, email_data: CreateEmail, session: session_dependency + contact_id: int, + email_data: CreateEmail, + session: session_dependency, + user: amp_admin_dependency, ) -> EmailResponse: contact = simple_get_by_id(session, Contact, contact_id) - return add_email(session, contact.id, email_data) + return add_email(session, contact.id, email_data, user=user) @router.post( @@ -150,10 +159,13 @@ def add_email_to_contact( status_code=status.HTTP_201_CREATED, ) def add_phone_to_contact( - contact_id: int, phone_data: CreatePhone, session: session_dependency + contact_id: int, + phone_data: CreatePhone, + session: session_dependency, + user: amp_admin_dependency, ) -> PhoneResponse: contact = simple_get_by_id(session, Contact, contact_id) - return add_phone(session, contact.id, phone_data) + return add_phone(session, contact.id, phone_data, user=user) @router.post( @@ -165,10 +177,13 @@ def add_thing_association_to_contact( contact_id: int, thing_association_data: CreateThingAssociation, session: session_dependency, + user: amp_admin_dependency, ) -> ThingContactAssociationResponse: contact = simple_get_by_id(session, Contact, contact_id) try: - return add_thing_association(session, contact.id, thing_association_data) + return add_thing_association( + session, contact.id, thing_association_data, user=user + ) except ProgrammingError as e: database_error_handler(thing_association_data, e) @@ -185,11 +200,12 @@ def update_contact_email( email_id: int, email_data: UpdateEmail, session: session_dependency, + user: amp_editor_dependency, ) -> EmailResponse: """ Update an existing contact's email in the database. """ - return model_patcher(session, Email, email_id, email_data) + return model_patcher(session, Email, email_id, email_data, user=user) @router.patch( @@ -199,6 +215,7 @@ def update_contact_phone( phone_id: int, phone_data: UpdatePhone, session: session_dependency, + user: amp_editor_dependency, ) -> PhoneResponse: """ Update an existing contact's phone number in the database. @@ -208,7 +225,7 @@ def update_contact_phone( :param session: Database session :return: Updated contact response """ - return model_patcher(session, Phone, phone_id, phone_data) + return model_patcher(session, Phone, phone_id, phone_data, user=user) @router.patch( @@ -218,6 +235,7 @@ def update_contact_address( address_id: int, address_data: UpdateAddress, session: session_dependency, + user: amp_editor_dependency, ) -> AddressResponse: """ Update an existing contact's address in the database. @@ -227,7 +245,7 @@ def update_contact_address( :param session: :return: """ - return model_patcher(session, Address, address_id, address_data) + return model_patcher(session, Address, address_id, address_data, user=user) @router.patch( @@ -238,6 +256,7 @@ def update_thing_contact_association( thing_contact_association_id: int, thing_contact_association_data: UpdateThingContactAssociation, session: session_dependency, + user: amp_editor_dependency, ) -> ThingContactAssociationResponse: """ Update an existing thing-contact association in the database. @@ -253,6 +272,7 @@ def update_thing_contact_association( ThingContactAssociation, thing_contact_association_id, thing_contact_association_data, + user=user, ) except ProgrammingError as e: database_error_handler(thing_contact_association_data, e) @@ -263,6 +283,7 @@ def update_contact( contact_id: int, contact_data: UpdateContact, session: session_dependency, + user: amp_editor_dependency, ) -> ContactResponse: """ Update an existing contact in the database. @@ -271,14 +292,16 @@ def update_contact( :param session: Database session :return: Updated contact response """ - return model_patcher(session, Contact, contact_id, contact_data) + return model_patcher(session, Contact, contact_id, contact_data, user=user) # ====== GET =================================================================== @router.get("/email", summary="Get all emails") -async def get_emails(session: session_dependency) -> CustomPage[EmailResponse]: +async def get_emails( + session: session_dependency, user: amp_viewer_dependency +) -> CustomPage[EmailResponse]: """ Retrieve all emails from the database. :param session: @@ -288,7 +311,9 @@ async def get_emails(session: session_dependency) -> CustomPage[EmailResponse]: @router.get("/email/{email_id}", summary="Get email by ID") -async def get_email_by_id(email_id: int, session: session_dependency) -> EmailResponse: +async def get_email_by_id( + email_id: int, session: session_dependency, user: amp_viewer_dependency +) -> EmailResponse: """ Retrieve an email by ID from the database. """ @@ -296,7 +321,9 @@ async def get_email_by_id(email_id: int, session: session_dependency) -> EmailRe @router.get("/phone", summary="Get all phones") -async def get_phones(session: session_dependency) -> CustomPage[PhoneResponse]: +async def get_phones( + session: session_dependency, user: amp_viewer_dependency +) -> CustomPage[PhoneResponse]: """ Retrieve all phone numbers from the database. :param session: @@ -306,7 +333,9 @@ async def get_phones(session: session_dependency) -> CustomPage[PhoneResponse]: @router.get("/phone/{phone_id}", summary="Get phone by ID") -async def get_phone_by_id(phone_id: int, session: session_dependency) -> PhoneResponse: +async def get_phone_by_id( + phone_id: int, session: session_dependency, user: amp_viewer_dependency +) -> PhoneResponse: """ Retrieve a phone by ID from the database. """ @@ -314,7 +343,9 @@ async def get_phone_by_id(phone_id: int, session: session_dependency) -> PhoneRe @router.get("/address", summary="Get all addresses") -async def get_addresses(session: session_dependency) -> CustomPage[AddressResponse]: +async def get_addresses( + session: session_dependency, user: amp_viewer_dependency +) -> CustomPage[AddressResponse]: """ Retrieve all addresses from the database. :param session: @@ -325,7 +356,7 @@ async def get_addresses(session: session_dependency) -> CustomPage[AddressRespon @router.get("/address/{address_id}", summary="Get address by ID") async def get_address_by_id( - address_id: int, session: session_dependency + address_id: int, session: session_dependency, user: amp_viewer_dependency ) -> AddressResponse: """ Retrieve an address by ID from the database. @@ -335,7 +366,7 @@ async def get_address_by_id( @router.get("/thing-association", summary="Get all thing-contact associations") async def get_thing_contact_associations( - session: session_dependency, + session: session_dependency, user: amp_viewer_dependency ) -> CustomPage[ThingContactAssociationResponse]: """ Retrieve all thing-contact associations from the database. @@ -350,7 +381,9 @@ async def get_thing_contact_associations( summary="Get thing-contact association by ID", ) async def get_thing_contact_association_by_id( - thing_contact_association_id: int, session: session_dependency + thing_contact_association_id: int, + session: session_dependency, + user: amp_viewer_dependency, ) -> ThingContactAssociationResponse: """ Retrieve a thing-contact association by ID from the database. @@ -363,6 +396,7 @@ async def get_thing_contact_association_by_id( @router.get("", summary="Get contacts") async def get_contacts( session: session_dependency, + user: amp_viewer_dependency, sort: str = None, order: str = None, filter_: str = Query(alias="filter", default=None), @@ -386,7 +420,7 @@ async def get_contacts( @router.get("/{contact_id}", summary="Get contact by ID") async def get_contact_by_id( - contact_id: int, session: session_dependency + contact_id: int, session: session_dependency, user: amp_viewer_dependency ) -> ContactResponse: """ Retrieve a contact by ID from the database. @@ -396,7 +430,7 @@ async def get_contact_by_id( @router.get("/{contact_id}/email", summary="Get contact emails") async def get_contact_emails( - contact_id: int, session: session_dependency + contact_id: int, session: session_dependency, user: amp_viewer_dependency ) -> CustomPage[EmailResponse]: """ Retrieve all emails associated with a contact. @@ -408,7 +442,7 @@ async def get_contact_emails( @router.get("/{contact_id}/phone", summary="Get contact phones") async def get_contact_phones( - contact_id: int, session: session_dependency + contact_id: int, session: session_dependency, user: amp_viewer_dependency ) -> CustomPage[PhoneResponse]: """ Retrieve all phone numbers associated with a contact. @@ -420,7 +454,7 @@ async def get_contact_phones( @router.get("/{contact_id}/address", summary="Get contact addresses") async def get_contact_addresses( - contact_id: int, session: session_dependency + contact_id: int, session: session_dependency, user: amp_viewer_dependency ) -> CustomPage[AddressResponse]: """ Retrieve all addresses associated with a contact. @@ -432,7 +466,7 @@ async def get_contact_addresses( @router.get("/{contact_id}/thing-association", summary="Get contact's things") async def get_contact_thing_associations( - contact_id: int, session: session_dependency + contact_id: int, session: session_dependency, user: amp_viewer_dependency ) -> CustomPage[ThingContactAssociationResponse]: """ Retrieve all thing-contact associations for a contact. @@ -448,7 +482,9 @@ async def get_contact_thing_associations( @router.delete("/email/{email_id}", summary="Delete contact email") -def delete_contact_email(email_id: int, session: session_dependency): +def delete_contact_email( + email_id: int, session: session_dependency, user: amp_admin_dependency +): """ Delete a contact email by ID from the database. """ @@ -456,7 +492,9 @@ def delete_contact_email(email_id: int, session: session_dependency): @router.delete("/phone/{phone_id}", summary="Delete contact phone") -def delete_contact_phone(phone_id: int, session: session_dependency): +def delete_contact_phone( + phone_id: int, session: session_dependency, user: amp_admin_dependency +): """ Delete a contact phone by ID from the database. """ @@ -464,7 +502,9 @@ def delete_contact_phone(phone_id: int, session: session_dependency): @router.delete("/address/{address_id}", summary="Delete contact address") -def delete_contact_address(address_id: int, session: session_dependency): +def delete_contact_address( + address_id: int, session: session_dependency, user: amp_admin_dependency +): """ Delete a contact address by ID from the database. """ @@ -476,7 +516,9 @@ def delete_contact_address(address_id: int, session: session_dependency): summary="Delete contact thing association", ) def delete_contact_thing_association( - thing_contact_association_id: int, session: session_dependency + thing_contact_association_id: int, + session: session_dependency, + user: amp_admin_dependency, ): """ Delete a contact's thing association from the database @@ -485,7 +527,9 @@ def delete_contact_thing_association( @router.delete("/{contact_id}", summary="Delete contact") -def delete_contact(contact_id: int, session: session_dependency): +def delete_contact( + contact_id: int, session: session_dependency, user: amp_admin_dependency +): """ Delete a contact by ID from the database. """ diff --git a/services/people_helper.py b/services/contact_helper.py similarity index 100% rename from services/people_helper.py rename to services/contact_helper.py diff --git a/tests/test_contact.py b/tests/test_contact.py index 14d585caf..0478897f4 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -1,5 +1,11 @@ +from core.dependencies import ( + amp_viewer_function, + amp_editor_function, + amp_admin_function, +) from db import Contact, Address, Email, Phone, ThingContactAssociation from db.engine import session_ctx +from main import app from tests import client, cleanup_post_test, cleanup_patch_test from schemas.contact import ValidateEmail, ValidatePhone @@ -7,6 +13,29 @@ from pydantic import ValidationError import re + +def override_authentication(default=True): + """ + Override the authentication dependency for testing purposes. + This allows all users to be considered authenticated. + """ + + def closure(): + print("Overriding authentication") + return default + + return closure + + +app.dependency_overrides[amp_admin_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} +) +app.dependency_overrides[amp_editor_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} +) +app.dependency_overrides[amp_viewer_function] = override_authentication() + + # ============= module & function fixtures ======================================= From 18ea5f6a4dc7b77320aee19dda0bfc2db1b6ce3f Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Fri, 15 Aug 2025 15:54:20 -0600 Subject: [PATCH 44/56] refactor: put audit add in init for reuse for all helpers --- services/__init__.py | 8 ++++++++ services/thing_helper.py | 8 +------- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/services/__init__.py b/services/__init__.py index 8e546ddc2..01dc0cb3b 100644 --- a/services/__init__.py +++ b/services/__init__.py @@ -14,4 +14,12 @@ # limitations under the License. # =============================================================================== + +def audit_add(user, obj): + # TODO: see note in "AuditMixin" + if user: + obj.created_by_id = user["sub"] + obj.created_by_name = user["name"] + + # ============= EOF ============================================= diff --git a/services/thing_helper.py b/services/thing_helper.py index 4b073e3f8..05b7154aa 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -21,6 +21,7 @@ from db import LocationThingAssociation, Thing, Base, Location from schemas.location import LocationResponse from db.group import Group, GroupThingAssociation +from services import audit_add from services.geospatial_helper import make_within_wkt from services.query_helper import make_query, order_sort_filter from shapely import wkb @@ -107,13 +108,6 @@ def transformer(records): return paginate(query=sql, conn=session, transformer=transformer) -def audit_add(user, obj): - # TODO: see note in "AuditMixin" - if user: - obj.created_by_id = user["sub"] - obj.created_by_name = user["name"] - - # REFACTOR TODO: use enums (or enum-like object) for thing_type def add_thing( session: Session, data: BaseModel | dict, thing_type: str = None, user: dict = None From 4263cdb0c707b84abfa0e752d44f2a04d6ed1928 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Fri, 15 Aug 2025 15:56:44 -0600 Subject: [PATCH 45/56] refactor: put audit add in audit helper --- services/__init__.py | 7 ------- services/audit_helper.py | 5 +++++ 2 files changed, 5 insertions(+), 7 deletions(-) create mode 100644 services/audit_helper.py diff --git a/services/__init__.py b/services/__init__.py index 01dc0cb3b..850ec5839 100644 --- a/services/__init__.py +++ b/services/__init__.py @@ -15,11 +15,4 @@ # =============================================================================== -def audit_add(user, obj): - # TODO: see note in "AuditMixin" - if user: - obj.created_by_id = user["sub"] - obj.created_by_name = user["name"] - - # ============= EOF ============================================= diff --git a/services/audit_helper.py b/services/audit_helper.py new file mode 100644 index 000000000..df8ae39ff --- /dev/null +++ b/services/audit_helper.py @@ -0,0 +1,5 @@ +def audit_add(user, obj): + # TODO: see note in "AuditMixin" + if user: + obj.created_by_id = user["sub"] + obj.created_by_name = user["name"] From 81e7d471c65c91c83c27d07adb759e2a0ff5d909 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Fri, 15 Aug 2025 15:58:38 -0600 Subject: [PATCH 46/56] feat: implement auth for contact router --- services/contact_helper.py | 35 +++++++++++++++++++---------------- services/thing_helper.py | 2 +- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/services/contact_helper.py b/services/contact_helper.py index 7901eebc8..a31cbd4a6 100644 --- a/services/contact_helper.py +++ b/services/contact_helper.py @@ -21,12 +21,12 @@ CreatePhone, CreateThingAssociation, ) +from services.audit_helper import audit_add from sqlalchemy.orm import Session def add_contact( - session: Session, - contact_data: CreateContact | dict, + session: Session, contact_data: CreateContact | dict, user: dict ) -> Contact: """ Add a new contact to the database. @@ -62,6 +62,11 @@ def add_contact( contact.addresses.append(address) # session.add(address) + audit_add(user, contact) + audit_add(user, contact.emails) + audit_add(user, contact.phones) + audit_add(user, contact.addresses) + session.add(contact) session.flush() session.refresh(contact) @@ -70,6 +75,8 @@ def add_contact( location_contact_association.thing_id = contact_data.get("thing_id") location_contact_association.contact_id = contact.id + audit_add(user, location_contact_association) + session.add(location_contact_association) # owner_contact_association = OwnerContactAssociation() # owner_contact_association.owner_id = owner.id @@ -85,9 +92,7 @@ def add_contact( def add_address( - session: Session, - contact_id: int, - address_data: dict, + session: Session, contact_id: int, address_data: dict, user: dict ) -> Address: """ Add an address to a contact. @@ -96,6 +101,7 @@ def add_address( address_data = address_data.model_dump(exclude_unset=True) address = Address(**address_data, contact_id=contact_id) + audit_add(user, address) session.add(address) session.commit() session.refresh(address) @@ -103,11 +109,7 @@ def add_address( return address -def add_email( - session: Session, - contact_id: int, - email_data: dict, -) -> Email: +def add_email(session: Session, contact_id: int, email_data: dict, user: dict) -> Email: """ Add an email to a contact. """ @@ -115,6 +117,7 @@ def add_email( email_data = email_data.model_dump(exclude_unset=True) email = Email(**email_data, contact_id=contact_id) + audit_add(user, email) session.add(email) session.commit() session.refresh(email) @@ -122,11 +125,7 @@ def add_email( return email -def add_phone( - session: Session, - contact_id: int, - phone_data: dict, -) -> Phone: +def add_phone(session: Session, contact_id: int, phone_data: dict, user: dict) -> Phone: """ Add a phone number to a contact. """ @@ -134,6 +133,7 @@ def add_phone( phone_data = phone_data.model_dump(exclude_unset=True) phone = Phone(**phone_data, contact_id=contact_id) + audit_add(user, phone) session.add(phone) session.commit() session.refresh(phone) @@ -142,7 +142,7 @@ def add_phone( def add_thing_association( - session: Session, contact_id: int, thing_association_data: dict + session: Session, contact_id: int, thing_association_data: dict, user: dict ): if isinstance(thing_association_data, CreateThingAssociation): thing_association_data = thing_association_data.model_dump(exclude_unset=True) @@ -150,6 +150,9 @@ def add_thing_association( thing_association = ThingContactAssociation( **thing_association_data, contact_id=contact_id ) + + audit_add(user, thing_association) + session.add(thing_association) session.commit() session.refresh(thing_association) diff --git a/services/thing_helper.py b/services/thing_helper.py index 05b7154aa..de5b06c60 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -21,7 +21,7 @@ from db import LocationThingAssociation, Thing, Base, Location from schemas.location import LocationResponse from db.group import Group, GroupThingAssociation -from services import audit_add +from services.audit_helper import audit_add from services.geospatial_helper import make_within_wkt from services.query_helper import make_query, order_sort_filter from shapely import wkb From 9c8c4c3a2c210c4ceaab1e3af1ea092c3acfb090 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Fri, 15 Aug 2025 16:07:31 -0600 Subject: [PATCH 47/56] fix: update test search to use fixtures --- tests/conftest.py | 2 +- tests/test_search.py | 62 ++++++++++++++++++++++---------------------- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index bef7cca04..4c76ed8f7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -144,7 +144,7 @@ def email(contact): def phone(contact): with session_ctx() as session: phone = Phone( - phone_number="505-123-4567", phone_type="Mobile", contact_id=contact.id + phone_number="+15051234567", phone_type="Mobile", contact_id=contact.id ) session.add(phone) session.commit() diff --git a/tests/test_search.py b/tests/test_search.py index 7daef334b..877e37ce5 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -30,7 +30,7 @@ def test_search_api(thing, sample): assert isinstance(data, dict) items = data.get("items") assert isinstance(items, list) - assert len(items) == 3 + assert len(items) == 1 @pytest.mark.skip(reason="This test is not working .") @@ -54,42 +54,42 @@ def test_search_api3(): assert len(items) == 0 -def test_search_contact(): +def test_search_contact(contact): with session_ctx() as session: query = search(select(Contact), "Test") - contact = session.scalars(query).first() - assert contact is not None + queried_contact = session.scalars(query).first() + assert queried_contact is not None -def test_search_contact_no_results(): +def test_search_contact_no_results(contact): with session_ctx() as session: query = search(select(Contact), "NonExistent") - contact = session.scalars(query).first() - assert contact is None + queried_contact = session.scalars(query).first() + assert queried_contact is None -def test_search_contact_like(): +def test_search_contact_like(contact): with session_ctx() as session: query = search(select(Contact), "Te") - contact = session.scalars(query).first() - assert contact is not None + queried_contact = session.scalars(query).first() + assert queried_contact is not None -def test_search_contact_by_email(): +def test_search_contact_by_email(contact, email): with session_ctx() as session: vector = Contact.search_vector | Email.search_vector query = search( select(Contact).join(Email), - "fasdfasdf@gmail.co", + "test@example.com", vector=vector, ) - contact = session.scalars(query).first() - assert contact is not None + queried_contact = session.scalars(query).first() + assert queried_contact is not None -def test_search_contact_by_email_no_results(): +def test_search_contact_by_email_no_results(contact, email): with session_ctx() as session: vector = Contact.search_vector | Email.search_vector query = search( @@ -97,23 +97,23 @@ def test_search_contact_by_email_no_results(): "foo", vector=vector, ) - contact = session.scalars(query).first() - assert contact is None + queried_contact = session.scalars(query).first() + assert queried_contact is None -def test_search_contact_by_phone_number(): +def test_search_contact_by_phone_number(contact, phone): with session_ctx() as session: vector = Contact.search_vector | Phone.search_vector query = search( select(Contact).join(Phone), - "+12345678901", + "+15051234567", vector=vector, ) - contact = session.scalars(query).first() - assert contact is not None + queried_contact = session.scalars(query).first() + assert queried_contact is not None -def test_search_contact_by_phone_number_no_results(): +def test_search_contact_by_phone_number_no_results(contact, phone): with session_ctx() as session: vector = Contact.search_vector | Phone.search_vector query = search( @@ -121,23 +121,23 @@ def test_search_contact_by_phone_number_no_results(): "+12345678902", vector=vector, ) - contact = session.scalars(query).first() - assert contact is None + queried_contact = session.scalars(query).first() + assert queried_contact is None -def test_search_contact_by_phone_like(): +def test_search_contact_by_phone_like(contact, phone): with session_ctx() as session: vector = Contact.search_vector | Phone.search_vector query = search( select(Contact).join(Phone), - "+12", + "+15", vector=vector, ) - contact = session.scalars(query).first() - assert contact is not None + queried_contact = session.scalars(query).first() + assert queried_contact is not None -def test_search_contact_by_phone_like_no_results(): +def test_search_contact_by_phone_like_no_results(contact, phone): with session_ctx() as session: vector = Contact.search_vector | Phone.search_vector query = search( @@ -145,8 +145,8 @@ def test_search_contact_by_phone_like_no_results(): "+99", vector=vector, ) - contact = session.scalars(query).first() - assert contact is None + queried_contact = session.scalars(query).first() + assert queried_contact is None # def test_search_owner_by_contact_name(): From 1df9faf1f2911d9be7df20e578761bf9f1bfead2 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Fri, 15 Aug 2025 16:10:46 -0600 Subject: [PATCH 48/56] fix: fix pydantic style exception for sensor --- api/sensor.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/api/sensor.py b/api/sensor.py index bf908c080..da772c6f5 100644 --- a/api/sensor.py +++ b/api/sensor.py @@ -63,16 +63,18 @@ def update_sensor( existing_datetime_removed is not None and sensor_data.datetime_installed >= existing_datetime_removed ): - raise PydanticStyleException( - status_code=status.HTTP_409_CONFLICT, - loc=["body", "datetime_installed"], - msg=f"new datetime installed must be before existing datetime removed of {existing_datetime_removed.isoformat().replace('+00:00', 'Z')}", - type="value_error", - input={ + detail = { + "loc": ["body", "datetime_installed"], + "msg": f"new datetime installed must be before existing datetime removed of {existing_datetime_removed.isoformat().replace('+00:00', 'Z')}", + "type": "value_error", + "input": { "datetime_installed": sensor_data.datetime_installed.isoformat().replace( "+00:00", "Z" ) }, + } + raise PydanticStyleException( + status_code=status.HTTP_409_CONFLICT, detail=[detail] ) elif ( sensor_data.datetime_installed is None @@ -81,16 +83,18 @@ def update_sensor( sensor = simple_get_by_id(session, Sensor, sensor_id) existing_datetime_installed = sensor.datetime_installed if sensor_data.datetime_removed <= existing_datetime_installed: - raise PydanticStyleException( - status_code=status.HTTP_409_CONFLICT, - loc=["body", "datetime_removed"], - msg=f"new datetime removed must be after existing datetime installed of {existing_datetime_installed.isoformat().replace('+00:00', 'Z')}", - type="value_error", - input={ + detail = { + "loc": ["body", "datetime_removed"], + "msg": f"new datetime removed must be after existing datetime installed of {existing_datetime_installed.isoformat().replace('+00:00', 'Z')}", + "type": "value_error", + "input": { "datetime_removed": sensor_data.datetime_removed.isoformat().replace( "+00:00", "Z" ) }, + } + raise PydanticStyleException( + status_code=status.HTTP_409_CONFLICT, detail=[detail] ) return model_patcher(session, Sensor, sensor_id, sensor_data) From 2a23163459f2e9bc2ce97a9bfdd4d2ece5e911ea Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Fri, 15 Aug 2025 16:22:34 -0600 Subject: [PATCH 49/56] fix: fix asset schema/db/tests to correspond and use fixtures --- api/asset.py | 2 -- db/asset.py | 1 + tests/conftest.py | 20 +++++++++++++++ tests/test_asset.py | 59 +++++++++++--------------------------------- tests/test_search.py | 6 ++++- 5 files changed, 40 insertions(+), 48 deletions(-) diff --git a/api/asset.py b/api/asset.py index 63d31b7fd..a6c3f293b 100644 --- a/api/asset.py +++ b/api/asset.py @@ -125,7 +125,6 @@ async def add_asset( data = asset_data.model_dump() thing_id = data.pop("thing_id", None) - url = data.pop("url", "") data["storage_service"] = "gcs" asset = Asset(**data) @@ -140,7 +139,6 @@ async def add_asset( session.add(asset) session.commit() session.refresh(asset) - asset.url = url return asset diff --git a/db/asset.py b/db/asset.py index fe5ed2be9..840393429 100644 --- a/db/asset.py +++ b/db/asset.py @@ -33,6 +33,7 @@ class Asset(Base, AutoBaseMixin): storage_path = Column(String, nullable=False) mime_type = Column(String, nullable=False) size = Column(Integer, nullable=False) + url = Column(String, nullable=False) search_vector = Column( TSVectorType("name", "mime_type", "storage_service", "storage_path") diff --git a/tests/conftest.py b/tests/conftest.py index 4c76ed8f7..146b60fab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -152,3 +152,23 @@ def phone(contact): yield phone session.close() + + +@pytest.fixture(scope="session") +def asset(): + with session_ctx() as session: + asset = Asset( + name="Test Asset", + label="test label", + mime_type="image/png", + size=12345, + storage_service="mock_service", + storage_path="mock/path/to/asset", + url="https://storage.googleapis.com/mock-bucket/mock-asset", + ) + session.add(asset) + session.commit() + session.refresh(asset) + yield asset + + session.close() diff --git a/tests/test_asset.py b/tests/test_asset.py index a27015270..02911dfb7 100644 --- a/tests/test_asset.py +++ b/tests/test_asset.py @@ -15,7 +15,8 @@ # =============================================================================== from api.asset import get_storage_bucket from core.app import app -from tests import client +from db import Asset +from tests import client, cleanup_post_test class MockBlob: @@ -52,16 +53,9 @@ def test_upload_asset(): assert response.status_code == 201 data = response.json() assert "storage_path" in data - # assert data["name"] == "riochama.png" - # assert data["label"] == "riochama.png" - # assert data["storage_service"] == "mock_service" - # assert data["storage_path"] == "mock/path/to/asset" - # assert data["mime_type"] == "image/png" - # assert data["size"] == 12345 - # assert data["url"] == "https://storage.googleapis.com/mock-bucket/mock-asset" -def test_add_asset(location, thing): +def test_add_asset(thing): resp = client.post( "/asset", json={ @@ -75,25 +69,11 @@ def test_add_asset(location, thing): }, ) - print(resp.json()) assert resp.status_code == 201 data = resp.json() assert data["name"] == "riochama.png" - # assert data["label"] == "Test Asset" - # path = "tests/data/riochama.png" - # - # with open(path, "rb") as file: - # response = client.post( - # "/asset", - # params={"thing_id": thing.id}, - # files={"file": ("riochama.png", file, "image/png")}, - # ) - # - # data = response.json() - # assert response.status_code == 201 - # assert data["name"] == "riochama.png" - # url = data["url"] - # assert url.startswith("https://storage.googleapis.com/") + + cleanup_post_test(Asset, data["id"]) def test_add_asset_with_label(thing): @@ -114,28 +94,17 @@ def test_add_asset_with_label(thing): data = resp.json() assert data["name"] == "test_asset.png" assert data["label"] == "Test Asset" - # path = "tests/data/riochama.png" - # - # with open(path, "rb") as file: - # response = client.post( - # "/asset", - # params={'label': 'test label'}, - # files={"file": ("riochama.png", file, "image/png")}, - # ) - # - # assert response.status_code == 201 - # data = response.json() - # assert data["name"] == "riochama.png" - # assert data["label"] == "test label" - - -def test_get_asset(): - response = client.get("/asset/1") + + cleanup_post_test(Asset, data["id"]) + + +def test_get_asset(asset): + response = client.get(f"/asset/{asset.id}") assert response.status_code == 200 data = response.json() - assert data["id"] == 1 - assert data["name"] == "riochama.png" - assert data["url"] == "https://storage.googleapis.com/mock-bucket/mock-asset" + assert data["id"] == asset.id + assert data["name"] == asset.name + assert data["url"] == asset.url def test_get_asset_not_found(): diff --git a/tests/test_search.py b/tests/test_search.py index 877e37ce5..180688960 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -22,11 +22,15 @@ from tests import client -def test_search_api(thing, sample): +def test_search_api(thing, contact, email, phone, address): response = client.get("/search", params={"q": "Test"}) assert response.status_code == 200 data = response.json() + from pprint import pprint + + pprint(data, indent=2) + assert isinstance(data, dict) items = data.get("items") assert isinstance(items, list) From 4c96841b9d3d08c6d598b84e7ad10ae35ec54a2d Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Fri, 15 Aug 2025 16:25:46 -0600 Subject: [PATCH 50/56] fix: fix test_search_api --- tests/test_search.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_search.py b/tests/test_search.py index 180688960..6cd3cb0eb 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -34,7 +34,7 @@ def test_search_api(thing, contact, email, phone, address): assert isinstance(data, dict) items = data.get("items") assert isinstance(items, list) - assert len(items) == 1 + assert len(items) == 2 @pytest.mark.skip(reason="This test is not working .") From 173370a798e5191041246010c321d94e22b73359 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Fri, 15 Aug 2025 16:26:48 -0600 Subject: [PATCH 51/56] refactor: removed print debugger --- tests/test_search.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/test_search.py b/tests/test_search.py index 6cd3cb0eb..620c74cc2 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -26,11 +26,6 @@ def test_search_api(thing, contact, email, phone, address): response = client.get("/search", params={"q": "Test"}) assert response.status_code == 200 data = response.json() - - from pprint import pprint - - pprint(data, indent=2) - assert isinstance(data, dict) items = data.get("items") assert isinstance(items, list) From fcc3b35c32f24f30898266feb44ff8ffcdd7070a Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Mon, 18 Aug 2025 09:56:11 -0600 Subject: [PATCH 52/56] refactor: override auth in fixture to make tests independent --- tests/__init__.py | 13 ++++++++++++ tests/test_contact.py | 30 +++++++++++--------------- tests/test_geospatial.py | 34 ++++++++++++++++++++++++++++- tests/test_thing.py | 46 +++++++++++++++++----------------------- 4 files changed, 78 insertions(+), 45 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 5d7220f57..495f72077 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -29,6 +29,19 @@ client = TestClient(app) +def override_authentication(default=True): + """ + Override the authentication dependency for testing purposes. + This allows all users to be considered authenticated. + """ + + def closure(): + print("Overriding authentication") + return default + + return closure + + def cleanup_post_test(model: Base, new_record_id: int) -> None: """ Function to cleanup POST tests diff --git a/tests/test_contact.py b/tests/test_contact.py index 0478897f4..6a458aaf1 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -6,7 +6,7 @@ from db import Contact, Address, Email, Phone, ThingContactAssociation from db.engine import session_ctx from main import app -from tests import client, cleanup_post_test, cleanup_patch_test +from tests import client, cleanup_post_test, cleanup_patch_test, override_authentication from schemas.contact import ValidateEmail, ValidatePhone import pytest @@ -14,26 +14,20 @@ import re -def override_authentication(default=True): - """ - Override the authentication dependency for testing purposes. - This allows all users to be considered authenticated. - """ +@pytest.fixture(scope="module", autouse=True) +def override_authentication_dependency_fixture(): - def closure(): - print("Overriding authentication") - return default - - return closure + app.dependency_overrides[amp_admin_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[amp_editor_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[amp_viewer_function] = override_authentication() + yield -app.dependency_overrides[amp_admin_function] = override_authentication( - default={"name": "foobar", "sub": "1234567890"} -) -app.dependency_overrides[amp_editor_function] = override_authentication( - default={"name": "foobar", "sub": "1234567890"} -) -app.dependency_overrides[amp_viewer_function] = override_authentication() + app.dependency_overrides = {} # ============= module & function fixtures ======================================= diff --git a/tests/test_geospatial.py b/tests/test_geospatial.py index 11d5f9060..a9746246c 100644 --- a/tests/test_geospatial.py +++ b/tests/test_geospatial.py @@ -16,11 +16,43 @@ from pathlib import Path import pytest +from main import app +from core.dependencies import ( + admin_function, + editor_function, + amp_admin_function, + amp_editor_function, + viewer_function, + amp_viewer_function, +) from db import Thing, Location, LocationThingAssociation from db.engine import session_ctx -from tests import client +from tests import client, override_authentication from geoalchemy2 import functions as geofunc + +@pytest.fixture(scope="module", autouse=True) +def override_authentication_dependency_fixture(): + app.dependency_overrides[admin_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[editor_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[viewer_function] = override_authentication() + app.dependency_overrides[amp_admin_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[amp_editor_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[amp_viewer_function] = override_authentication() + + yield + + app.dependency_overrides = {} + + # @pytest.fixture(scope="module", autouse=True) # def location_fixture(): # client.post( diff --git a/tests/test_thing.py b/tests/test_thing.py index 04f57e92e..8c0cfd0b7 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -13,7 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from tests import client +import pytest +from tests import client, override_authentication from main import app from core.dependencies import ( admin_function, @@ -25,33 +26,26 @@ ) -def override_authentication(default=True): - """ - Override the authentication dependency for testing purposes. - This allows all users to be considered authenticated. - """ - - def closure(): - print("Overriding authentication") - return default - - return closure +@pytest.fixture(scope="module", autouse=True) +def override_authentication_dependency_fixture(): + app.dependency_overrides[admin_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[editor_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[viewer_function] = override_authentication() + app.dependency_overrides[amp_admin_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[amp_editor_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[amp_viewer_function] = override_authentication() + yield -app.dependency_overrides[admin_function] = override_authentication( - default={"name": "foobar", "sub": "1234567890"} -) -app.dependency_overrides[editor_function] = override_authentication( - default={"name": "foobar", "sub": "1234567890"} -) -app.dependency_overrides[viewer_function] = override_authentication() -app.dependency_overrides[amp_admin_function] = override_authentication( - default={"name": "foobar", "sub": "1234567890"} -) -app.dependency_overrides[amp_editor_function] = override_authentication( - default={"name": "foobar", "sub": "1234567890"} -) -app.dependency_overrides[amp_viewer_function] = override_authentication() + app.dependency_overrides = {} def test_add_group(): From 65784071394803d536eb9507af6140b8ec684025 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 20 Aug 2025 12:37:56 -0600 Subject: [PATCH 53/56] fix: fix artifacts/idiosyncracies from pre-production merge --- db/contact.py | 8 ++++---- db/thing.py | 14 ++++++++------ tests/conftest.py | 2 +- tests/test_asset.py | 27 ++++++++++++++++++++++----- tests/test_group.py | 21 ++++++++++++++++++++- tests/test_lexicon.py | 23 ++++++++++++++++++++++- tests/test_lexicon_pagination.py | 23 ++++++++++++++++++++++- 7 files changed, 99 insertions(+), 19 deletions(-) diff --git a/db/contact.py b/db/contact.py index 66c609e91..f29709e61 100644 --- a/db/contact.py +++ b/db/contact.py @@ -29,6 +29,9 @@ class ThingContactAssociation(Base, AutoBaseMixin): Integer, ForeignKey("contact.id", ondelete="CASCADE"), nullable=False ) + contact = relationship("Contact") + thing = relationship("Thing") + class Contact(Base, AutoBaseMixin): name = Column(String(100), nullable=False) @@ -37,10 +40,6 @@ class Contact(Base, AutoBaseMixin): 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) - # email = Column(String(100), nullable=True) - # phone = Column(String(20), nullable=True) - # owner_id = Column(Integer, ForeignKey("owner.id"), nullable=False) - # owner = relationship("Owner") search_vector = Column(TSVectorType("name", "role")) @@ -54,6 +53,7 @@ class Contact(Base, AutoBaseMixin): "ThingContactAssociation", back_populates="contact", cascade="all, delete-orphan", + passive_deletes=True, ) things = association_proxy("thing_associations", "thing") diff --git a/db/thing.py b/db/thing.py index 22958c347..7ebd20f30 100644 --- a/db/thing.py +++ b/db/thing.py @@ -45,6 +45,14 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin): ) locations = association_proxy("location_associations", "location") + contact_associations = relationship( + "ThingContactAssociation", + back_populates="thing", + overlaps="contacts", + cascade="all, delete-orphan", + ) + contacts = association_proxy("contact_associations", "contact") + # Well fields well_depth = Column( Float, @@ -74,12 +82,6 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin): samples = relationship( "Sample", back_populates="thing", cascade="all, delete-orphan", uselist=True ) - contacts = relationship( - "Contact", - secondary="thing_contact_association", - back_populates="things", - passive_deletes=True, - ) class ThingIdLink(Base, AutoBaseMixin): diff --git a/tests/conftest.py b/tests/conftest.py index 146b60fab..b535f3be5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -164,7 +164,7 @@ def asset(): size=12345, storage_service="mock_service", storage_path="mock/path/to/asset", - url="https://storage.googleapis.com/mock-bucket/mock-asset", + uri="https://storage.googleapis.com/mock-bucket/mock-asset", ) session.add(asset) session.commit() diff --git a/tests/test_asset.py b/tests/test_asset.py index 1d2d3d508..8c146a17d 100644 --- a/tests/test_asset.py +++ b/tests/test_asset.py @@ -15,8 +15,11 @@ # =============================================================================== from api.asset import get_storage_bucket from core.app import app +from core.dependencies import viewer_function, admin_function, editor_function from db import Asset -from tests import client, cleanup_post_test +from tests import client, cleanup_post_test, override_authentication + +import pytest class MockBlob: @@ -41,9 +44,23 @@ def mock_storage_bucket(): return MockStorageBucket() -app.dependency_overrides = { - get_storage_bucket: mock_storage_bucket, -} +@pytest.fixture(scope="module", autouse=True) +def override_dependency_fixture(): + app.dependency_overrides = { + get_storage_bucket: mock_storage_bucket, + } + + app.dependency_overrides[viewer_function] = override_authentication() + app.dependency_overrides[admin_function] = override_authentication( + default={"name": "test", "sub": "314159"} + ) + app.dependency_overrides[editor_function] = override_authentication( + default={"name": "test", "sub": "314159"} + ) + + yield + + app.dependency_overrides = {} def test_upload_asset(): @@ -109,7 +126,7 @@ def test_get_asset(asset): data = response.json() assert data["id"] == asset.id assert data["name"] == asset.name - assert data["url"] == asset.url + assert data["uri"] == MockBlob().generate_signed_url() def test_get_asset_not_found(): diff --git a/tests/test_group.py b/tests/test_group.py index 9989b5c9d..e652339a7 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -1,5 +1,24 @@ import pytest -from tests import client + +from core.dependencies import admin_function, viewer_function, editor_function +from main import app +from tests import client, override_authentication + + +@pytest.fixture(scope="module", autouse=True) +def override_authentication_dependency_fixture(): + + app.dependency_overrides[admin_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[editor_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[viewer_function] = override_authentication() + + yield + + app.dependency_overrides = {} # ADD tests ====================================================== diff --git a/tests/test_lexicon.py b/tests/test_lexicon.py index 79e0a9fe1..91c152d26 100644 --- a/tests/test_lexicon.py +++ b/tests/test_lexicon.py @@ -14,7 +14,28 @@ # limitations under the License. # =============================================================================== from services.validation import get_category -from tests import client +from tests import client, override_authentication + +from core.dependencies import admin_function, viewer_function, editor_function +from main import app + +import pytest + + +@pytest.fixture(scope="module", autouse=True) +def override_authentication_dependency_fixture(): + + app.dependency_overrides[admin_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[editor_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[viewer_function] = override_authentication() + + yield + + app.dependency_overrides = {} def test_add_lexicon_category(): diff --git a/tests/test_lexicon_pagination.py b/tests/test_lexicon_pagination.py index c9688737b..f5255f4d5 100644 --- a/tests/test_lexicon_pagination.py +++ b/tests/test_lexicon_pagination.py @@ -13,7 +13,28 @@ # See the License for the specific language governing permissions and # limitations under the License. # ============================================================================== -from tests import client +from tests import client, override_authentication + +from core.dependencies import admin_function, viewer_function, editor_function +from main import app + +import pytest + + +@pytest.fixture(scope="module", autouse=True) +def override_authentication_dependency_fixture(): + + app.dependency_overrides[admin_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[editor_function] = override_authentication( + default={"name": "foobar", "sub": "1234567890"} + ) + app.dependency_overrides[viewer_function] = override_authentication() + + yield + + app.dependency_overrides = {} def test_get_lexicon_terms_sort_categories_branch(): From c69934ff374331b454a9db09974c62acd69f197e Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 20 Aug 2025 12:54:54 -0600 Subject: [PATCH 54/56] refactor: comment out thing-association from contact router --- api/contact.py | 199 ++++++++++++++------------- tests/conftest.py | 14 +- tests/test_contact.py | 306 +++++++++++++++++++++--------------------- 3 files changed, 255 insertions(+), 264 deletions(-) diff --git a/api/contact.py b/api/contact.py index 8f63bfc45..e6f6011c1 100644 --- a/api/contact.py +++ b/api/contact.py @@ -38,12 +38,10 @@ EmailResponse, AddressResponse, ContactResponse, - ThingContactAssociationResponse, UpdateContact, UpdateEmail, UpdatePhone, UpdateAddress, - UpdateThingContactAssociation, ) from services.crud_helper import model_patcher, model_deleter from services.contact_helper import ( @@ -51,7 +49,6 @@ add_address, add_email, add_phone, - add_thing_association, ) from services.query_helper import ( simple_get_by_id, @@ -168,24 +165,24 @@ def add_phone_to_contact( return add_phone(session, contact.id, phone_data, user=user) -@router.post( - "/{contact_id}/thing-association", - summary="Add a thing-contact association to a contact", - status_code=status.HTTP_201_CREATED, -) -def add_thing_association_to_contact( - contact_id: int, - thing_association_data: CreateThingAssociation, - session: session_dependency, - user: amp_admin_dependency, -) -> ThingContactAssociationResponse: - contact = simple_get_by_id(session, Contact, contact_id) - try: - return add_thing_association( - session, contact.id, thing_association_data, user=user - ) - except ProgrammingError as e: - database_error_handler(thing_association_data, e) +# @router.post( +# "/{contact_id}/thing-association", +# summary="Add a thing-contact association to a contact", +# status_code=status.HTTP_201_CREATED, +# ) +# def add_thing_association_to_contact( +# contact_id: int, +# thing_association_data: CreateThingAssociation, +# session: session_dependency, +# user: amp_admin_dependency, +# ) -> ThingContactAssociationResponse: +# contact = simple_get_by_id(session, Contact, contact_id) +# try: +# return add_thing_association( +# session, contact.id, thing_association_data, user=user +# ) +# except ProgrammingError as e: +# database_error_handler(thing_association_data, e) # PATCH ======================================================================== @@ -248,34 +245,34 @@ def update_contact_address( return model_patcher(session, Address, address_id, address_data, user=user) -@router.patch( - "/thing-association/{thing_contact_association_id}", - summary="Update thing-contact association", -) -def update_thing_contact_association( - thing_contact_association_id: int, - thing_contact_association_data: UpdateThingContactAssociation, - session: session_dependency, - user: amp_editor_dependency, -) -> ThingContactAssociationResponse: - """ - Update an existing thing-contact association in the database. - - :param thing_contact_association_id: - :param thing_contact_association_data: - :param session: - :return: - """ - try: - return model_patcher( - session, - ThingContactAssociation, - thing_contact_association_id, - thing_contact_association_data, - user=user, - ) - except ProgrammingError as e: - database_error_handler(thing_contact_association_data, e) +# @router.patch( +# "/thing-association/{thing_contact_association_id}", +# summary="Update thing-contact association", +# ) +# def update_thing_contact_association( +# thing_contact_association_id: int, +# thing_contact_association_data: UpdateThingContactAssociation, +# session: session_dependency, +# user: amp_editor_dependency, +# ) -> ThingContactAssociationResponse: +# """ +# Update an existing thing-contact association in the database. + +# :param thing_contact_association_id: +# :param thing_contact_association_data: +# :param session: +# :return: +# """ +# try: +# return model_patcher( +# session, +# ThingContactAssociation, +# thing_contact_association_id, +# thing_contact_association_data, +# user=user, +# ) +# except ProgrammingError as e: +# database_error_handler(thing_contact_association_data, e) @router.patch("/{contact_id}", summary="Update contact") @@ -364,33 +361,33 @@ async def get_address_by_id( return simple_get_by_id(session, Address, address_id) -@router.get("/thing-association", summary="Get all thing-contact associations") -async def get_thing_contact_associations( - session: session_dependency, user: amp_viewer_dependency -) -> CustomPage[ThingContactAssociationResponse]: - """ - Retrieve all thing-contact associations from the database. - :param session: - :return: - """ - return paginated_all_getter(session, ThingContactAssociation) - - -@router.get( - "/thing-association/{thing_contact_association_id}", - summary="Get thing-contact association by ID", -) -async def get_thing_contact_association_by_id( - thing_contact_association_id: int, - session: session_dependency, - user: amp_viewer_dependency, -) -> ThingContactAssociationResponse: - """ - Retrieve a thing-contact association by ID from the database. - """ - return simple_get_by_id( - session, ThingContactAssociation, thing_contact_association_id - ) +# @router.get("/thing-association", summary="Get all thing-contact associations") +# async def get_thing_contact_associations( +# session: session_dependency, user: amp_viewer_dependency +# ) -> CustomPage[ThingContactAssociationResponse]: +# """ +# Retrieve all thing-contact associations from the database. +# :param session: +# :return: +# """ +# return paginated_all_getter(session, ThingContactAssociation) + + +# @router.get( +# "/thing-association/{thing_contact_association_id}", +# summary="Get thing-contact association by ID", +# ) +# async def get_thing_contact_association_by_id( +# thing_contact_association_id: int, +# session: session_dependency, +# user: amp_viewer_dependency, +# ) -> ThingContactAssociationResponse: +# """ +# Retrieve a thing-contact association by ID from the database. +# """ +# return simple_get_by_id( +# session, ThingContactAssociation, thing_contact_association_id +# ) @router.get("", summary="Get contacts") @@ -464,18 +461,18 @@ async def get_contact_addresses( return paginate(query=sql, conn=session) -@router.get("/{contact_id}/thing-association", summary="Get contact's things") -async def get_contact_thing_associations( - contact_id: int, session: session_dependency, user: amp_viewer_dependency -) -> CustomPage[ThingContactAssociationResponse]: - """ - Retrieve all thing-contact associations for a contact. - """ - contact = simple_get_by_id(session, Contact, contact_id) - sql = select(ThingContactAssociation).where( - ThingContactAssociation.contact_id == contact.id - ) - return paginate(query=sql, conn=session) +# @router.get("/{contact_id}/thing-association", summary="Get contact's things") +# async def get_contact_thing_associations( +# contact_id: int, session: session_dependency, user: amp_viewer_dependency +# ) -> CustomPage[ThingContactAssociationResponse]: +# """ +# Retrieve all thing-contact associations for a contact. +# """ +# contact = simple_get_by_id(session, Contact, contact_id) +# sql = select(ThingContactAssociation).where( +# ThingContactAssociation.contact_id == contact.id +# ) +# return paginate(query=sql, conn=session) # DELETE ======================================================================= @@ -511,19 +508,19 @@ def delete_contact_address( return model_deleter(session, Address, address_id) -@router.delete( - "/thing-association/{thing_contact_association_id}", - summary="Delete contact thing association", -) -def delete_contact_thing_association( - thing_contact_association_id: int, - session: session_dependency, - user: amp_admin_dependency, -): - """ - Delete a contact's thing association from the database - """ - return model_deleter(session, ThingContactAssociation, thing_contact_association_id) +# @router.delete( +# "/thing-association/{thing_contact_association_id}", +# summary="Delete contact thing association", +# ) +# def delete_contact_thing_association( +# thing_contact_association_id: int, +# session: session_dependency, +# user: amp_admin_dependency, +# ): +# """ +# Delete a contact's thing association from the database +# """ +# return model_deleter(session, ThingContactAssociation, thing_contact_association_id) @router.delete("/{contact_id}", summary="Delete contact") diff --git a/tests/conftest.py b/tests/conftest.py index b535f3be5..957540ea2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -79,7 +79,7 @@ def sample(thing, sensor): @pytest.fixture(scope="session") -def contact(): +def contact(thing): with session_ctx() as session: contact = Contact( name="Test Contact", @@ -89,19 +89,13 @@ def contact(): session.commit() session.refresh(contact) - yield contact - - session.close() - - -@pytest.fixture(scope="session") -def thing_contact_association(contact, thing): - with session_ctx() as session: association = ThingContactAssociation(thing_id=thing.id, contact_id=contact.id) session.add(association) session.commit() session.refresh(association) - yield association + + yield contact + session.close() diff --git a/tests/test_contact.py b/tests/test_contact.py index 6a458aaf1..e16eb0e05 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -3,7 +3,7 @@ amp_editor_function, amp_admin_function, ) -from db import Contact, Address, Email, Phone, ThingContactAssociation +from db import Contact, Address, Email, Phone from db.engine import session_ctx from main import app from tests import client, cleanup_post_test, cleanup_patch_test, override_authentication @@ -107,19 +107,19 @@ def second_address(second_contact): session.close() -@pytest.fixture(scope="function") -def second_thing_contact_association(thing, second_contact): - with session_ctx() as session: - association = ThingContactAssociation( - thing_id=thing.id, contact_id=second_contact.id - ) - session.add(association) - session.commit() - session.refresh(association) - yield association - session.delete(association) - session.commit() - session.close() +# @pytest.fixture(scope="function") +# def second_thing_contact_association(thing, second_contact): +# with session_ctx() as session: +# association = ThingContactAssociation( +# thing_id=thing.id, contact_id=second_contact.id +# ) +# session.add(association) +# session.commit() +# session.refresh(association) +# yield association +# session.delete(association) +# session.commit() +# session.close() # VALIDATION tests ============================================================= @@ -322,39 +322,39 @@ def test_add_phone_404_contact_not_found(contact): assert data["detail"] == f"Contact with ID {bad_contact_id} not found." -def test_add_thing_association(thing, second_contact): - payload = {"thing_id": thing.id} - response = client.post( - f"/contact/{second_contact.id}/thing-association", json=payload - ) - data = response.json() - assert response.status_code == 201 - assert "id" in data - assert data["thing_id"] == thing.id - assert data["contact_id"] == second_contact.id +# def test_add_thing_association(thing, second_contact): +# payload = {"thing_id": thing.id} +# response = client.post( +# f"/contact/{second_contact.id}/thing-association", json=payload +# ) +# data = response.json() +# assert response.status_code == 201 +# assert "id" in data +# assert data["thing_id"] == thing.id +# assert data["contact_id"] == second_contact.id - cleanup_post_test(ThingContactAssociation, data["id"]) +# cleanup_post_test(ThingContactAssociation, data["id"]) -def test_add_thing_association_404_contact_not_found(contact, thing): - bad_contact_id = 99999 - payload = {"thing_id": thing.id} - response = client.post(f"/contact/{bad_contact_id}/thing-association", json=payload) - assert response.status_code == 404 - data = response.json() - assert data["detail"] == f"Contact with ID {bad_contact_id} not found." +# def test_add_thing_association_404_contact_not_found(contact, thing): +# bad_contact_id = 99999 +# payload = {"thing_id": thing.id} +# response = client.post(f"/contact/{bad_contact_id}/thing-association", json=payload) +# assert response.status_code == 404 +# data = response.json() +# assert data["detail"] == f"Contact with ID {bad_contact_id} not found." -def test_add_thing_association_409_thing_not_found(thing, contact): - bad_thing_id = 9999 - payload = {"thing_id": bad_thing_id} - response = client.post(f"/contact/{contact.id}/thing-association", json=payload) - assert response.status_code == 409 - data = response.json() - assert data["detail"][0]["msg"] == f"Thing with ID {bad_thing_id} not found." - assert data["detail"][0]["loc"] == ["body", "thing_id"] - assert data["detail"][0]["type"] == "value_error" - assert data["detail"][0]["input"] == {"thing_id": bad_thing_id} +# def test_add_thing_association_409_thing_not_found(thing, contact): +# bad_thing_id = 9999 +# payload = {"thing_id": bad_thing_id} +# response = client.post(f"/contact/{contact.id}/thing-association", json=payload) +# assert response.status_code == 409 +# data = response.json() +# assert data["detail"][0]["msg"] == f"Thing with ID {bad_thing_id} not found." +# assert data["detail"][0]["loc"] == ["body", "thing_id"] +# assert data["detail"][0]["type"] == "value_error" +# assert data["detail"][0]["input"] == {"thing_id": bad_thing_id} # GET tests ====================================================== @@ -584,51 +584,51 @@ def test_get_address_by_id_404_not_found(address): assert data["detail"] == f"Address with ID {bad_address_id} not found." -def test_get_thing_contact_associations(thing_contact_association): - response = client.get("/contact/thing-association") - assert response.status_code == 200 - data = response.json() - assert data["total"] == 1 - assert data["items"][0]["id"] == thing_contact_association.id - assert data["items"][0]["contact_id"] == thing_contact_association.contact_id - assert data["items"][0]["thing_id"] == thing_contact_association.thing_id +# def test_get_thing_contact_associations(thing_contact_association): +# response = client.get("/contact/thing-association") +# assert response.status_code == 200 +# data = response.json() +# assert data["total"] == 1 +# assert data["items"][0]["id"] == thing_contact_association.id +# assert data["items"][0]["contact_id"] == thing_contact_association.contact_id +# assert data["items"][0]["thing_id"] == thing_contact_association.thing_id -def test_get_contact_thing_contact_association(contact, thing_contact_association): - response = client.get(f"/contact/{contact.id}/thing-association") - assert response.status_code == 200 - data = response.json() - assert data["total"] == 1 - assert data["items"][0]["id"] == thing_contact_association.id - assert data["items"][0]["contact_id"] == thing_contact_association.contact_id - assert data["items"][0]["thing_id"] == thing_contact_association.thing_id +# def test_get_contact_thing_contact_association(contact, thing_contact_association): +# response = client.get(f"/contact/{contact.id}/thing-association") +# assert response.status_code == 200 +# data = response.json() +# assert data["total"] == 1 +# assert data["items"][0]["id"] == thing_contact_association.id +# assert data["items"][0]["contact_id"] == thing_contact_association.contact_id +# assert data["items"][0]["thing_id"] == thing_contact_association.thing_id -def test_get_thing_contact_association_404_contact_not_found( - contact, thing_contact_association -): - bad_contact_id = 999999 - response = client.get(f"/contact/{bad_contact_id}/thing-association") - assert response.status_code == 404 - data = response.json() - assert data["detail"] == f"Contact with ID {bad_contact_id} not found." +# def test_get_thing_contact_association_404_contact_not_found( +# contact, thing_contact_association +# ): +# bad_contact_id = 999999 +# response = client.get(f"/contact/{bad_contact_id}/thing-association") +# assert response.status_code == 404 +# data = response.json() +# assert data["detail"] == f"Contact with ID {bad_contact_id} not found." -def test_get_thing_contact_association_by_id(thing_contact_association): - response = client.get(f"/contact/thing-association/{thing_contact_association.id}") - assert response.status_code == 200 - data = response.json() - assert data["id"] == thing_contact_association.id - assert data["contact_id"] == thing_contact_association.contact_id - assert data["thing_id"] == thing_contact_association.thing_id +# def test_get_thing_contact_association_by_id(thing_contact_association): +# response = client.get(f"/contact/thing-association/{thing_contact_association.id}") +# assert response.status_code == 200 +# data = response.json() +# assert data["id"] == thing_contact_association.id +# assert data["contact_id"] == thing_contact_association.contact_id +# assert data["thing_id"] == thing_contact_association.thing_id -def test_get_thing_contact_association_by_id_404_not_found(thing_contact_association): - bad_id = 999999 - response = client.get(f"/contact/thing-association/{bad_id}") - assert response.status_code == 404 - data = response.json() - assert data["detail"] == f"ThingContactAssociation with ID {bad_id} not found." +# def test_get_thing_contact_association_by_id_404_not_found(thing_contact_association): +# bad_id = 999999 +# response = client.get(f"/contact/thing-association/{bad_id}") +# assert response.status_code == 404 +# data = response.json() +# assert data["detail"] == f"ThingContactAssociation with ID {bad_id} not found." # PATCH tests ================================================================== @@ -747,54 +747,54 @@ def test_patch_address_404_not_found(address): assert data["detail"] == f"Address with ID {bad_address_id} not found." -def test_patch_thing_contact_association(thing_contact_association, second_contact): - payload = {"contact_id": second_contact.id} - response = client.patch( - f"/contact/thing-association/{thing_contact_association.id}", json=payload - ) - data = response.json() - assert response.status_code == 200 - assert data["id"] == thing_contact_association.id - assert data["contact_id"] == payload["contact_id"] - - cleanup_patch_test(ThingContactAssociation, payload, thing_contact_association) - - -def test_patch_thing_contact_association_404_not_found( - thing_contact_association, second_contact -): - bad_id = 999999 - payload = {"contact_id": second_contact.id} - response = client.patch(f"/contact/thing-association/{bad_id}", json=payload) - assert response.status_code == 404 - data = response.json() - assert data["detail"] == f"ThingContactAssociation with ID {bad_id} not found." - - -def test_patch_thing_contact_association_409_contact_not_found( - thing_contact_association, -): - bad_contact_id = 999999 - payload = {"contact_id": bad_contact_id} - response = client.patch( - f"/contact/thing-association/{thing_contact_association.id}", json=payload - ) - assert response.status_code == 409 - data = response.json() - assert len(data["detail"]) == 1 - assert data["detail"][0]["msg"] == f"Contact with ID {bad_contact_id} not found." - - -def test_patch_thing_contact_association_409_thing_not_found(thing_contact_association): - bad_thing_id = 999999 - payload = {"thing_id": bad_thing_id} - response = client.patch( - f"/contact/thing-association/{thing_contact_association.id}", json=payload - ) - assert response.status_code == 409 - data = response.json() - assert len(data["detail"]) == 1 - assert data["detail"][0]["msg"] == f"Thing with ID {bad_thing_id} not found." +# def test_patch_thing_contact_association(thing_contact_association, second_contact): +# payload = {"contact_id": second_contact.id} +# response = client.patch( +# f"/contact/thing-association/{thing_contact_association.id}", json=payload +# ) +# data = response.json() +# assert response.status_code == 200 +# assert data["id"] == thing_contact_association.id +# assert data["contact_id"] == payload["contact_id"] + +# cleanup_patch_test(ThingContactAssociation, payload, thing_contact_association) + + +# def test_patch_thing_contact_association_404_not_found( +# thing_contact_association, second_contact +# ): +# bad_id = 999999 +# payload = {"contact_id": second_contact.id} +# response = client.patch(f"/contact/thing-association/{bad_id}", json=payload) +# assert response.status_code == 404 +# data = response.json() +# assert data["detail"] == f"ThingContactAssociation with ID {bad_id} not found." + + +# def test_patch_thing_contact_association_409_contact_not_found( +# thing_contact_association, +# ): +# bad_contact_id = 999999 +# payload = {"contact_id": bad_contact_id} +# response = client.patch( +# f"/contact/thing-association/{thing_contact_association.id}", json=payload +# ) +# assert response.status_code == 409 +# data = response.json() +# assert len(data["detail"]) == 1 +# assert data["detail"][0]["msg"] == f"Contact with ID {bad_contact_id} not found." + + +# def test_patch_thing_contact_association_409_thing_not_found(thing_contact_association): +# bad_thing_id = 999999 +# payload = {"thing_id": bad_thing_id} +# response = client.patch( +# f"/contact/thing-association/{thing_contact_association.id}", json=payload +# ) +# assert response.status_code == 409 +# data = response.json() +# assert len(data["detail"]) == 1 +# assert data["detail"][0]["msg"] == f"Thing with ID {bad_thing_id} not found." # DELETE tests ================================================================= @@ -909,29 +909,29 @@ def test_delete_address_404_not_found(second_address): assert data["detail"] == f"Address with ID {bad_address_id} not found." -def test_delete_thing_contact_association(second_thing_contact_association): - response = client.delete( - f"/contact/thing-association/{second_thing_contact_association.id}" - ) - assert response.status_code == 204 - - # verify association is deleted - response = client.get( - f"/contact/thing-association/{second_thing_contact_association.id}" - ) - assert response.status_code == 404 - data = response.json() - assert ( - data["detail"] - == f"ThingContactAssociation with ID {second_thing_contact_association.id} not found." - ) - - -def test_delete_thing_contact_association_404_not_found( - second_thing_contact_association, -): - bad_id = 999999 - response = client.delete(f"/contact/thing-association/{bad_id}") - assert response.status_code == 404 - data = response.json() - assert data["detail"] == f"ThingContactAssociation with ID {bad_id} not found." +# def test_delete_thing_contact_association(second_thing_contact_association): +# response = client.delete( +# f"/contact/thing-association/{second_thing_contact_association.id}" +# ) +# assert response.status_code == 204 + +# # verify association is deleted +# response = client.get( +# f"/contact/thing-association/{second_thing_contact_association.id}" +# ) +# assert response.status_code == 404 +# data = response.json() +# assert ( +# data["detail"] +# == f"ThingContactAssociation with ID {second_thing_contact_association.id} not found." +# ) + + +# def test_delete_thing_contact_association_404_not_found( +# second_thing_contact_association, +# ): +# bad_id = 999999 +# response = client.delete(f"/contact/thing-association/{bad_id}") +# assert response.status_code == 404 +# data = response.json() +# assert data["detail"] == f"ThingContactAssociation with ID {bad_id} not found." From d32717ad8c9bd1b5a277ffa3d3447567ca833dd7 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 20 Aug 2025 13:23:35 -0600 Subject: [PATCH 55/56] refactor: put contact_id in payload not path POST email|phone|address --- api/contact.py | 68 +++++++++++++++++++++++++---------- schemas/contact.py | 4 +++ services/contact_helper.py | 73 -------------------------------------- tests/__init__.py | 2 +- tests/test_contact.py | 65 +++++++++++++++++++++++---------- 5 files changed, 100 insertions(+), 112 deletions(-) diff --git a/api/contact.py b/api/contact.py index e6f6011c1..0525e4126 100644 --- a/api/contact.py +++ b/api/contact.py @@ -27,7 +27,7 @@ amp_editor_dependency, amp_viewer_dependency, ) -from db import ThingContactAssociation, Thing, Contact, Email, Phone, Address +from db import ThingContactAssociation, Thing, Contact, Email, Phone, Address, adder from schemas.contact import ( CreateContact, CreateAddress, @@ -46,9 +46,6 @@ from services.crud_helper import model_patcher, model_deleter from services.contact_helper import ( add_contact, - add_address, - add_email, - add_phone, ) from services.query_helper import ( simple_get_by_id, @@ -92,6 +89,36 @@ def database_error_handler( "type": "value_error", "input": {"contact_id": payload.contact_id}, } + elif ( + error_message + == 'insert or update on table "email" violates foreign key constraint "email_contact_id_fkey"' + ): + detail = { + "loc": ["body", "contact_id"], + "msg": f"Contact with ID {payload.contact_id} not found.", + "type": "value_error", + "input": {"contact_id": payload.contact_id}, + } + elif ( + error_message + == 'insert or update on table "phone" violates foreign key constraint "phone_contact_id_fkey"' + ): + detail = { + "loc": ["body", "contact_id"], + "msg": f"Contact with ID {payload.contact_id} not found.", + "type": "value_error", + "input": {"contact_id": payload.contact_id}, + } + elif ( + error_message + == 'insert or update on table "address" violates foreign key constraint "address_contact_id_fkey"' + ): + detail = { + "loc": ["body", "contact_id"], + "msg": f"Contact with ID {payload.contact_id} not found.", + "type": "value_error", + "input": {"contact_id": payload.contact_id}, + } raise PydanticStyleException(status_code=status.HTTP_409_CONFLICT, detail=[detail]) @@ -114,12 +141,11 @@ def create_contact( @router.post( - "/{contact_id}/address", + "/address", summary="Add an address to a contact", status_code=status.HTTP_201_CREATED, ) -def add_address_to_contact( - contact_id: int, +def create_address( address_data: CreateAddress, session: session_dependency, user: amp_admin_dependency, @@ -131,38 +157,42 @@ def add_address_to_contact( :param session: Database session :return: Response containing the added address """ - contact = simple_get_by_id(session, Contact, contact_id) - return add_address(session, contact.id, address_data, user=user) + try: + return adder(session, Address, address_data, user=user) + except ProgrammingError as e: + database_error_handler(address_data, e) @router.post( - "/{contact_id}/email", + "/email", summary="Add an email to a contact", status_code=status.HTTP_201_CREATED, ) -def add_email_to_contact( - contact_id: int, +def create_email( email_data: CreateEmail, session: session_dependency, user: amp_admin_dependency, ) -> EmailResponse: - contact = simple_get_by_id(session, Contact, contact_id) - return add_email(session, contact.id, email_data, user=user) + try: + return adder(session, Email, email_data, user=user) + except ProgrammingError as e: + database_error_handler(email_data, e) @router.post( - "/{contact_id}/phone", + "/phone", summary="Add a phone number to a contact", status_code=status.HTTP_201_CREATED, ) -def add_phone_to_contact( - contact_id: int, +def create_phone( phone_data: CreatePhone, session: session_dependency, user: amp_admin_dependency, ) -> PhoneResponse: - contact = simple_get_by_id(session, Contact, contact_id) - return add_phone(session, contact.id, phone_data, user=user) + try: + return adder(session, Phone, phone_data, user=user) + except ProgrammingError as e: + database_error_handler(phone_data, e) # @router.post( diff --git a/schemas/contact.py b/schemas/contact.py index 414d81f6f..c8ff04439 100644 --- a/schemas/contact.py +++ b/schemas/contact.py @@ -70,6 +70,7 @@ class CreateEmail(ValidateEmail): Schema for creating an email. """ + contact_id: int | None = None # set to None for when made via POST /contact email: str email_type: str = "Primary" # Default to 'Primary' @@ -79,6 +80,7 @@ class CreatePhone(ValidatePhone): Schema for creating a phone number. """ + contact_id: int | None = None # set to None for when made via POST /contact phone_number: str phone_type: str = "Primary" # Default to 'Primary' @@ -88,6 +90,7 @@ class CreateAddress(BaseModel): Schema for creating an address. """ + contact_id: int | None = None # set to None for when made via POST /contact # todo: use a postal API to validate address and suggest corrections address_line_1: str # Required (e.g., "123 Main St") address_line_2: str | None = None # Optional (e.g., "Apt 4B", "Suite 200") @@ -104,6 +107,7 @@ class CreateThingAssociation(BaseModel): Schema for creating a ContactThingAssociation """ + contact_id: int thing_id: int diff --git a/services/contact_helper.py b/services/contact_helper.py index a31cbd4a6..8860da7a2 100644 --- a/services/contact_helper.py +++ b/services/contact_helper.py @@ -15,11 +15,7 @@ # =============================================================================== from db.contact import Contact, Email, Phone, Address, ThingContactAssociation from schemas.contact import ( - CreateAddress, CreateContact, - CreateEmail, - CreatePhone, - CreateThingAssociation, ) from services.audit_helper import audit_add from sqlalchemy.orm import Session @@ -91,73 +87,4 @@ def add_contact( return contact -def add_address( - session: Session, contact_id: int, address_data: dict, user: dict -) -> Address: - """ - Add an address to a contact. - """ - if isinstance(address_data, CreateAddress): - address_data = address_data.model_dump(exclude_unset=True) - - address = Address(**address_data, contact_id=contact_id) - audit_add(user, address) - session.add(address) - session.commit() - session.refresh(address) - - return address - - -def add_email(session: Session, contact_id: int, email_data: dict, user: dict) -> Email: - """ - Add an email to a contact. - """ - if isinstance(email_data, CreateEmail): - email_data = email_data.model_dump(exclude_unset=True) - - email = Email(**email_data, contact_id=contact_id) - audit_add(user, email) - session.add(email) - session.commit() - session.refresh(email) - - return email - - -def add_phone(session: Session, contact_id: int, phone_data: dict, user: dict) -> Phone: - """ - Add a phone number to a contact. - """ - if isinstance(phone_data, CreatePhone): - phone_data = phone_data.model_dump(exclude_unset=True) - - phone = Phone(**phone_data, contact_id=contact_id) - audit_add(user, phone) - session.add(phone) - session.commit() - session.refresh(phone) - - return phone - - -def add_thing_association( - session: Session, contact_id: int, thing_association_data: dict, user: dict -): - if isinstance(thing_association_data, CreateThingAssociation): - thing_association_data = thing_association_data.model_dump(exclude_unset=True) - - thing_association = ThingContactAssociation( - **thing_association_data, contact_id=contact_id - ) - - audit_add(user, thing_association) - - session.add(thing_association) - session.commit() - session.refresh(thing_association) - - return thing_association - - # ============= EOF ============================================= diff --git a/tests/__init__.py b/tests/__init__.py index 495f72077..cb3f6b192 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -36,7 +36,7 @@ def override_authentication(default=True): """ def closure(): - print("Overriding authentication") + # print("Overriding authentication") return default return closure diff --git a/tests/test_contact.py b/tests/test_contact.py index e16eb0e05..dd80ffea6 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -237,6 +237,7 @@ def test_add_contact_409_bad_thing_id(): def test_add_address(contact): payload = { + "contact_id": contact.id, "address_line_1": "456 Secondary St", "address_line_2": "Apt 12A", "city": "Test Metropolis", @@ -245,7 +246,7 @@ def test_add_address(contact): "country": "United States", "address_type": "Primary", } - response = client.post(f"/contact/{contact.id}/address", json=payload) + response = client.post("/contact/address", json=payload) data = response.json() assert response.status_code == 201 assert "id" in data @@ -261,9 +262,10 @@ def test_add_address(contact): cleanup_post_test(Address, data["id"]) -def test_add_address_404_contact_not_found(contact): +def test_add_address_409_contact_not_found(contact): bad_contact_id = 9999 payload = { + "contact_id": bad_contact_id, "address_line_1": "456 Secondary St", "address_line_2": "Apt 12A", "city": "Test Metropolis", @@ -272,15 +274,22 @@ def test_add_address_404_contact_not_found(contact): "country": "United States", "address_type": "Secondary", } - response = client.post(f"/contact/{bad_contact_id}/address", json=payload) - assert response.status_code == 404 + response = client.post("/contact/address", json=payload) + assert response.status_code == 409 data = response.json() - assert data["detail"] == f"Contact with ID {bad_contact_id} not found." + assert data["detail"][0]["msg"] == f"Contact with ID {bad_contact_id} not found." + assert data["detail"][0]["loc"] == ["body", "contact_id"] + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"contact_id": bad_contact_id} def test_add_email(contact): - payload = {"email": "anothertestemail@nmt.edu", "email_type": "Primary"} - response = client.post(f"/contact/{contact.id}/email", json=payload) + payload = { + "contact_id": contact.id, + "email": "anothertestemail@nmt.edu", + "email_type": "Primary", + } + response = client.post("/contact/email", json=payload) data = response.json() assert response.status_code == 201 assert "id" in data @@ -291,18 +300,29 @@ def test_add_email(contact): cleanup_post_test(Email, data["id"]) -def test_add_email_404_contact_not_found(contact): +def test_add_email_409_contact_not_found(contact): bad_contact_id = 9999 - payload = {"email": "anothertestemail@nmt.edu", "email_type": "Primary"} - response = client.post(f"/contact/{bad_contact_id}/email", json=payload) - assert response.status_code == 404 + payload = { + "contact_id": bad_contact_id, + "email": "anothertestemail@nmt.edu", + "email_type": "Primary", + } + response = client.post("/contact/email", json=payload) + assert response.status_code == 409 data = response.json() - assert data["detail"] == f"Contact with ID {bad_contact_id} not found." + assert data["detail"][0]["msg"] == f"Contact with ID {bad_contact_id} not found." + assert data["detail"][0]["loc"] == ["body", "contact_id"] + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"contact_id": bad_contact_id} def test_add_phone(contact): - payload = {"phone_number": "+12345678901", "phone_type": "Primary"} - response = client.post(f"/contact/{contact.id}/phone", json=payload) + payload = { + "contact_id": contact.id, + "phone_number": "+12345678901", + "phone_type": "Primary", + } + response = client.post("/contact/phone", json=payload) data = response.json() assert response.status_code == 201 assert "id" in data @@ -313,13 +333,20 @@ def test_add_phone(contact): cleanup_post_test(Phone, data["id"]) -def test_add_phone_404_contact_not_found(contact): +def test_add_phone_409_contact_not_found(contact): bad_contact_id = 9999 - payload = {"phone_number": "+12345678901", "phone_type": "Primary"} - response = client.post(f"/contact/{bad_contact_id}/phone", json=payload) - assert response.status_code == 404 + payload = { + "contact_id": bad_contact_id, + "phone_number": "+12345678901", + "phone_type": "Primary", + } + response = client.post("/contact/phone", json=payload) + assert response.status_code == 409 data = response.json() - assert data["detail"] == f"Contact with ID {bad_contact_id} not found." + assert data["detail"][0]["msg"] == f"Contact with ID {bad_contact_id} not found." + assert data["detail"][0]["loc"] == ["body", "contact_id"] + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"contact_id": bad_contact_id} # def test_add_thing_association(thing, second_contact): From f747ece8dff3b3ed4a6c02f79fc510652db4d0f3 Mon Sep 17 00:00:00 2001 From: Jacob Brown Date: Wed, 20 Aug 2025 13:30:22 -0600 Subject: [PATCH 56/56] fix: use passive deletes for email | phone | address --- db/contact.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/db/contact.py b/db/contact.py index f29709e61..8d910a879 100644 --- a/db/contact.py +++ b/db/contact.py @@ -65,7 +65,7 @@ class Phone(Base, AutoBaseMixin): phone_number = Column(String(20), nullable=False) phone_type = lexicon_term(nullable=False) - contact = relationship("Contact", back_populates="phones") + contact = relationship("Contact", back_populates="phones", passive_deletes=True) search_vector = Column(TSVectorType("phone_number")) @@ -76,7 +76,7 @@ class Email(Base, AutoBaseMixin): email = Column(String(100), nullable=False) email_type = lexicon_term(nullable=False) - contact = relationship("Contact", back_populates="emails") + contact = relationship("Contact", back_populates="emails", passive_deletes=True) search_vector = Column(TSVectorType("email")) @@ -93,7 +93,7 @@ class Address(Base, AutoBaseMixin): country = lexicon_term(nullable=False, default="United States") address_type = lexicon_term(nullable=False) - contact = relationship("Contact", back_populates="addresses") + contact = relationship("Contact", back_populates="addresses", passive_deletes=True) search_vector = Column( TSVectorType( "address_line_1",