From 1c28a4cbfa996ff6e08327d7849062a773215673 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Wed, 10 Dec 2025 17:19:12 -0700 Subject: [PATCH 1/4] feat: add notes to contact the feature well-inventory-csv.feature requires notes to be added to the contact model. this update enables that to be done for all contacts. this work is being done in a separate branch so it can be implemented and inspected on its own --- core/enums.py | 1 + core/lexicon.json | 1 + db/contact.py | 11 ++++++++++- schemas/contact.py | 4 ++++ schemas/notes.py | 7 +++++-- services/contact_helper.py | 18 ++++++++++++++---- tests/conftest.py | 10 +++++++++- tests/test_contact.py | 22 ++++++++++++++++++++++ transfers/contact_transfer.py | 3 +-- 9 files changed, 67 insertions(+), 10 deletions(-) diff --git a/core/enums.py b/core/enums.py index 91b206cab..dee7e13d0 100644 --- a/core/enums.py +++ b/core/enums.py @@ -80,4 +80,5 @@ GeographicScale: type[Enum] = build_enum_from_lexicon_category("geographic_scale") Lithology: type[Enum] = build_enum_from_lexicon_category("lithology") FormationCode: type[Enum] = build_enum_from_lexicon_category("formation_code") +NoteType: type[Enum] = build_enum_from_lexicon_category("note_type") # ============= EOF ============================================= diff --git a/core/lexicon.json b/core/lexicon.json index 0d14be5ac..025a243e4 100644 --- a/core/lexicon.json +++ b/core/lexicon.json @@ -1170,6 +1170,7 @@ {"categories": ["note_type"], "term": "Water", "definition": "Water bearing zone information and other info from ose reports"}, {"categories": ["note_type"], "term": "Measuring", "definition": "Notes about measuring/visiting the well, on Access form"}, {"categories": ["note_type"], "term": "Coordinate", "definition": "Notes about a location's coordinates"}, + {"categories": ["note_type"], "term": "Communication", "definition": "Notes about communication preferences/requests for a contact"}, {"categories": ["well_pump_type"], "term": "Submersible", "definition": "Submersible"}, {"categories": ["well_pump_type"], "term": "Jet", "definition": "Jet Pump"}, {"categories": ["well_pump_type"], "term": "Line Shaft", "definition": "Line Shaft"}, diff --git a/db/contact.py b/db/contact.py index 558724df9..fa3146df1 100644 --- a/db/contact.py +++ b/db/contact.py @@ -21,6 +21,7 @@ from sqlalchemy_utils import TSVectorType from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term +from db.notes import NotesMixin if TYPE_CHECKING: from db.field import FieldEventParticipant, FieldEvent @@ -45,7 +46,7 @@ class ThingContactAssociation(Base, AutoBaseMixin): ) -class Contact(Base, AutoBaseMixin, ReleaseMixin): +class Contact(Base, AutoBaseMixin, ReleaseMixin, NotesMixin): name: Mapped[str] = mapped_column(String(100), nullable=True) organization: Mapped[str] = lexicon_term(nullable=True) role: Mapped[str] = lexicon_term(nullable=False) @@ -124,6 +125,14 @@ class Contact(Base, AutoBaseMixin, ReleaseMixin): UniqueConstraint("name", "organization", name="uq_contact_name_organization"), ) + @property + def communication_notes(self): + return self._get_notes("Communication") + + @property + def general_notes(self): + return self._get_notes("General") + class IncompleteNMAPhone(Base, AutoBaseMixin): """ diff --git a/schemas/contact.py b/schemas/contact.py index eeecd6bfd..6f475abae 100644 --- a/schemas/contact.py +++ b/schemas/contact.py @@ -22,6 +22,7 @@ from core.enums import Role, ContactType, PhoneType, EmailType, AddressType from schemas import BaseResponseModel, BaseCreateModel, BaseUpdateModel +from schemas.notes import CreateNote, NoteResponse # -------- VALIDATORS ---------- @@ -157,6 +158,7 @@ class CreateContact(BaseCreateModel, ValidateContact): emails: list[CreateEmail] | None = None phones: list[CreatePhone] | None = None addresses: list[CreateAddress] | None = None + notes: list[CreateNote] | None = None # -------- RESPONSE ---------- @@ -221,6 +223,8 @@ class ContactResponse(BaseResponseModel): phones: List[PhoneResponse] = [] addresses: List[AddressResponse] = [] things: List[ThingResponseForContact] = [] + communication_notes: List[NoteResponse] = [] + general_notes: List[NoteResponse] = [] @field_validator("incomplete_nma_phones", mode="before") def make_incomplete_nma_phone_str(cls, v: list) -> list: diff --git a/schemas/notes.py b/schemas/notes.py index 85c47ed9b..8b8d8c438 100644 --- a/schemas/notes.py +++ b/schemas/notes.py @@ -2,6 +2,9 @@ Pydantic models for the Notes table. """ +from core.enums import NoteType + +from pydantic import BaseModel from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel # -------- BASE SCHEMA: ---------- @@ -10,8 +13,8 @@ """ -class BaseNote: - note_type: str +class BaseNote(BaseModel): + note_type: NoteType content: str diff --git a/services/contact_helper.py b/services/contact_helper.py index 942293e70..983235387 100644 --- a/services/contact_helper.py +++ b/services/contact_helper.py @@ -62,6 +62,7 @@ def add_contact(session: Session, data: CreateContact | dict, user: dict) -> Con phone_data = data.pop("phones", []) address_data = data.pop("addresses", []) thing_id = data.pop("thing_id", None) + notes_data = data.pop("notes", None) contact_data = data """ @@ -104,12 +105,21 @@ def add_contact(session: Session, data: CreateContact | dict, user: dict) -> Con audit_add(user, location_contact_association) 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() + + if notes_data is not None: + for n in notes_data: + note = contact.add_note(n["content"], n["note_type"]) + session.add(note) + + session.commit() + session.refresh(contact) + + for note in contact.notes: + session.refresh(note) + except Exception as e: session.rollback() raise e diff --git a/tests/conftest.py b/tests/conftest.py index cd27b3cea..b8bbd9227 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,7 +20,7 @@ def location(): session.commit() session.refresh(loc) - note = loc.add_note("these are some test notes", "Other") + note = loc.add_note("these are some test notes", "General") session.add(note) session.commit() session.refresh(loc) @@ -356,6 +356,14 @@ def contact(water_well_thing): session.commit() session.refresh(association) + for content, note_type in [ + ("Communication note", "Communication"), + ("General note", "General"), + ]: + note = contact.add_note(content, note_type) + session.add(note) + session.commit() + yield contact session.delete(contact) session.delete(association) diff --git a/tests/test_contact.py b/tests/test_contact.py index 68422b0a6..2076168ad 100644 --- a/tests/test_contact.py +++ b/tests/test_contact.py @@ -108,6 +108,12 @@ def test_add_contact(spring_thing): "address_type": "Primary", } ], + "notes": [ + { + "note_type": "General", + "content": "This is a general note for the contact.", + } + ], } response = client.post("/contact", json=payload) data = response.json() @@ -158,6 +164,12 @@ def test_add_contact(spring_thing): ) assert data["release_status"] == payload["release_status"] + assert data["general_notes"][0]["note_type"] == "General" + assert ( + data["general_notes"][0]["content"] == "This is a general note for the contact." + ) + assert len(data["communication_notes"]) == 0 + cleanup_post_test(Contact, data["id"]) @@ -429,6 +441,11 @@ def test_get_contacts( assert data["items"][0]["addresses"][0]["address_type"] == address.address_type assert data["items"][0]["addresses"][0]["release_status"] == address.release_status + assert data["items"][0]["general_notes"][0]["note_type"] == "General" + assert data["items"][0]["general_notes"][0]["content"] == "General note" + assert data["items"][0]["communication_notes"][0]["note_type"] == "Communication" + assert data["items"][0]["communication_notes"][0]["content"] == "Communication note" + def test_get_contacts_by_thing_id(contact, second_contact, water_well_thing): response = client.get(f"/contact?thing_id={water_well_thing.id}") @@ -495,6 +512,11 @@ def test_get_contact_by_id( assert data["addresses"][0]["address_type"] == address.address_type assert data["addresses"][0]["release_status"] == address.release_status + assert data["general_notes"][0]["note_type"] == "General" + assert data["general_notes"][0]["content"] == "General note" + assert data["communication_notes"][0]["note_type"] == "Communication" + assert data["communication_notes"][0]["content"] == "Communication note" + def test_get_contact_by_id_404_not_found(contact): bad_contact_id = 99999 diff --git a/transfers/contact_transfer.py b/transfers/contact_transfer.py index 9168eab77..d5a9a44ad 100644 --- a/transfers/contact_transfer.py +++ b/transfers/contact_transfer.py @@ -365,8 +365,7 @@ def _make_contact_and_assoc(session, data, thing, added): from schemas.contact import CreateContact contact = CreateContact(**data) - contact_data = contact.model_dump() - contact_data.pop("thing_id") + contact_data = contact.model_dump(exclude=["thing_id", "notes"]) contact = Contact(**contact_data) session.add(contact) From 3508921572723497969ec7d29b9d61c9cc0f81ee Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 11 Dec 2025 08:17:39 -0700 Subject: [PATCH 2/4] feat: implement contact notes in well inventory import and API This commit adds support for contact notes in the well inventory import process and API. --- api/well_inventory.py | 11 ++++++++++- schemas/well_inventory.py | 4 ++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/api/well_inventory.py b/api/well_inventory.py index 5f4b072ab..a4e1a7c3d 100644 --- a/api/well_inventory.py +++ b/api/well_inventory.py @@ -84,6 +84,14 @@ def _make_location(model) -> Location: def _make_contact(model: WellInventoryRow, well: Thing, idx) -> dict: # add contact + notes = [] + for content, note_type in ( + (model.result_communication_preference, "Communication"), + (model.contact_special_instructions, "General"), + ): + if content is not None: + notes.append({"content": content, "note_type": note_type}) + emails = [] phones = [] addresses = [] @@ -126,6 +134,7 @@ def _make_contact(model: WellInventoryRow, well: Thing, idx) -> dict: "emails": emails, "phones": phones, "addresses": addresses, + "notes": notes, } @@ -482,7 +491,7 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) for note_content, note_type in ( (model.specific_location_of_well, "Access"), (model.special_requests, "General"), - (model.well_measuring_notes, "Measuring"), + (model.well_measuring_notes, "Sampling Procedure"), ): if note_content is not None: well_notes.append({"content": note_content, "note_type": note_type}) diff --git a/schemas/well_inventory.py b/schemas/well_inventory.py index 0524baea6..1a167e772 100644 --- a/schemas/well_inventory.py +++ b/schemas/well_inventory.py @@ -235,8 +235,8 @@ class WellInventoryRow(BaseModel): well_hole_status: Optional[str] = None monitoring_frequency: MonitoryFrequencyField = None - result_communication_preference: Optional[str] = None # TODO: needs as home - contact_special_requests_notes: Optional[str] = None # TODO: needs a home + result_communication_preference: Optional[str] = None + contact_special_requests_notes: Optional[str] = None sampling_scenario_notes: Optional[str] = None # TODO: needs a home well_measuring_notes: Optional[str] = None sample_possible: OptionalBool = None # TODO: needs a home From b4ed76e7b4dccf5b5bf9d2388dcb1a950498eca4 Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 11 Dec 2025 08:24:54 -0700 Subject: [PATCH 3/4] feat: refresh thing notes after adding If the notes are not refreshed then the notes in the immediate ThingResponse will use the enum members for `note_type` instead of the strings stored in the database. By refreshing the notes the proper string values are loaded and therefore the correct notes can be compiled for the different notes fields in the response --- services/thing_helper.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/thing_helper.py b/services/thing_helper.py index ec4e330d5..d6b563f23 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -355,6 +355,9 @@ def add_thing( session.commit() session.refresh(thing) + for note in thing.notes: + session.refresh(note) + except Exception as e: session.rollback() raise e From a20cfb97d372b59c177b4dc8b2c1798ef89c8e2a Mon Sep 17 00:00:00 2001 From: jacob-a-brown Date: Thu, 11 Dec 2025 09:30:24 -0700 Subject: [PATCH 4/4] feat: add sampling_scenario_notes as a Sampling Procedure note to well This commit adds sampling_scenario_notes as a Sampling Procedure note to the well that is being added via the well inventory csv upload --- api/well_inventory.py | 1 + schemas/well_inventory.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/api/well_inventory.py b/api/well_inventory.py index a4e1a7c3d..4f7769609 100644 --- a/api/well_inventory.py +++ b/api/well_inventory.py @@ -492,6 +492,7 @@ def _add_csv_row(session: Session, group: Group, model: WellInventoryRow, user) (model.specific_location_of_well, "Access"), (model.special_requests, "General"), (model.well_measuring_notes, "Sampling Procedure"), + (model.sampling_scenario_notes, "Sampling Procedure"), ): if note_content is not None: well_notes.append({"content": note_content, "note_type": note_type}) diff --git a/schemas/well_inventory.py b/schemas/well_inventory.py index 82177624e..aa4079664 100644 --- a/schemas/well_inventory.py +++ b/schemas/well_inventory.py @@ -237,7 +237,7 @@ class WellInventoryRow(BaseModel): result_communication_preference: Optional[str] = None contact_special_requests_notes: Optional[str] = None - sampling_scenario_notes: Optional[str] = None # TODO: needs a home + sampling_scenario_notes: Optional[str] = None well_measuring_notes: Optional[str] = None sample_possible: OptionalBool = None # TODO: needs a home