Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7d97f47
feat: Create new `Notes` table
ksmuczynski Oct 17, 2025
8ab40ff
doc: Updated `Notes` model docstring for clarity.
ksmuczynski Oct 20, 2025
7aff37d
feat: Add 'notable_type' category and values
ksmuczynski Oct 20, 2025
1c9db25
doc: Remove TODO comments
ksmuczynski Oct 20, 2025
8936e1a
feat: Add polymorphic NotesMixin to support multi-category notes
ksmuczynski Oct 20, 2025
d0a7b40
feat: Integrate NotesMixin into Location and Thing models
ksmuczynski Oct 20, 2025
c905273
feat: Add Notes schema and polymorphic relationships to Thing and Loc…
ksmuczynski Oct 22, 2025
550acce
fix: Resolve circular import in Notes model
ksmuczynski Oct 22, 2025
f18b715
fix: Resolve undefined pk_value in NotesMixin.add_note method
ksmuczynski Oct 22, 2025
6425d06
refactor: Update 'noteable_type' category to `note_type`
ksmuczynski Oct 22, 2025
e814635
fix: Standardize location notes as List[NoteResponse] across schemas
ksmuczynski Oct 22, 2025
1000991
Merge branch 'staging' into jir-kas-notes-model
jirhiker Nov 10, 2025
3267246
feat: enhance note management by adding release status and updating n…
jirhiker Nov 10, 2025
fc31bc8
fix: update note handling in CRUD operations to ensure session commit…
jirhiker Nov 10, 2025
40ed513
feat: get polymorphic record via function
jacob-a-brown Nov 10, 2025
23832ea
refactor: latest record must have null end date
jacob-a-brown Nov 10, 2025
ce1f522
refactor: move polymorphic record retrival to tests
jacob-a-brown Nov 10, 2025
d672c72
fix: call erase and rebuild db from core/initializers.py
jacob-a-brown Nov 12, 2025
e1ff3a3
feat: Add Notes schema and polymorphic relationships to Thing and Loc…
ksmuczynski Oct 22, 2025
a09348a
fix: Standardize location notes as List[NoteResponse] across schemas
ksmuczynski Oct 22, 2025
8ef4904
feat: enhance note management by adding release status and updating n…
jirhiker Nov 10, 2025
8837553
fix: streamline note handling by consolidating session commit and ref…
jirhiker Nov 12, 2025
3a31d0c
Merge branch 'refs/heads/staging' into jir-kas-notes-model
jirhiker Nov 14, 2025
e5897c8
refactor: rename notable_id and notable_type to target_id and target_…
jirhiker Nov 14, 2025
8cb720f
fix: comment out test notes and polymorphic relationship mappings in …
jirhiker Nov 14, 2025
9d07555
Merge branch 'staging' into jir-kas-notes-model
jirhiker Nov 17, 2025
774a4a2
feat: enhance notes functionality in well and location models
jirhiker Nov 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions core/initializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ def erase_and_rebuild_db(session: Session):
Base.metadata.drop_all(session.bind)
Base.metadata.create_all(session.bind)

init_lexicon()
init_parameter()


def init_lexicon(path: str = None) -> None:
if path is None:
Expand Down
11 changes: 10 additions & 1 deletion core/lexicon.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
{"name": "limit_type", "description": null},
{"name": "measurement_method", "description": null},
{"name": "monitoring_status", "description": null},
{"name": "note_type", "description": null},
{"name": "parameter_name", "description": null},
{"name": "organization", "description": null},
{"name": "parameter_type", "description": null},
Expand Down Expand Up @@ -673,6 +674,14 @@
{"categories": ["sensor_status"], "term": "In Service", "definition": "In Service"},
{"categories": ["sensor_status"], "term": "In Repair", "definition": "In Repair"},
{"categories": ["sensor_status"], "term": "Retired", "definition": "Retired"},
{"categories": ["sensor_status"], "term": "Lost", "definition": "Lost"}
{"categories": ["sensor_status"], "term": "Lost", "definition": "Lost"},
{"categories": ["note_type"], "term": "Access", "definition": "Access instructions, gate codes, permission requirements, etc."},
{"categories": ["note_type"], "term": "Construction", "definition": "Construction details, well development, drilling notes, etc. Could create separate `types` for each of these if needed."},
{"categories": ["note_type"], "term": "Maintenance", "definition": "Maintenance observations and issues."},
{"categories": ["note_type"], "term": "Historical", "definition": "Historical information or context about the well or location."},
{"categories": ["note_type"], "term": "Other", "definition": "Other types of notes that do not fit into the predefined categories."},
{"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"}

]
}
1 change: 1 addition & 0 deletions db/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from db.group import *
from db.lexicon import *
from db.location import *
from db.notes import *
from db.observation import *
from db.parameter import *
from db.permission import *
Expand Down
9 changes: 7 additions & 2 deletions db/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
7. An `AuditMixin` to add standard audit columns to tables.
"""

import re
from typing import TYPE_CHECKING

from sqlalchemy import (
Column,
DateTime,
Expand All @@ -52,9 +55,11 @@
mapped_column,
relationship,
)
from sqlalchemy_searchable import make_searchable
from sqlalchemy_continuum import make_versioned
import re
from sqlalchemy_searchable import make_searchable

if TYPE_CHECKING:
pass


make_versioned()
Expand Down
6 changes: 4 additions & 2 deletions db/location.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,13 @@
from constants import SRID_WGS84
from db.base import Base, AutoBaseMixin, ReleaseMixin
from db.lexicon import lexicon_term
from db.notes import NotesMixin

if TYPE_CHECKING:
from db.thing import Thing


class Location(Base, AutoBaseMixin, ReleaseMixin):
class Location(Base, AutoBaseMixin, ReleaseMixin, NotesMixin):
__versioned__ = {}

nma_pk_location: Mapped[UUID] = mapped_column(String(36), nullable=True)
Expand All @@ -55,7 +56,8 @@ class Location(Base, AutoBaseMixin, ReleaseMixin):
county: Mapped[str] = mapped_column(String(100), nullable=True)
state: Mapped[str] = mapped_column(String(100), nullable=True)
quad_name: Mapped[str] = mapped_column(String(100), nullable=True)
notes: Mapped[str] = mapped_column(Text, nullable=True)
# TODO: remove this 'notes' field in favor of using the polymorphic Notes table. Did not remove it yet to avoid breaking existing data model.
# notes: Mapped[str] = mapped_column(Text, nullable=True)
nma_notes_location: Mapped[str] = mapped_column(Text, nullable=True)
nma_coordinate_notes: Mapped[str] = mapped_column(Text, nullable=True)
elevation_accuracy: Mapped[float] = mapped_column(nullable=True)
Expand Down
128 changes: 128 additions & 0 deletions db/notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""
SQLAlchemy model for the Notes table.

This is a polymorphic table for storing all unstructured notes, categorized by
a note_type.

The Notes table should be used when a record might need more than one note,
when the notes need to be categorized, or when you need the ability to
search across all notes in the system. This is different from a dedicated
notes field on a specific table, which should be used to store a simple,
single-purpose attribute of the record itself.
"""

from typing import TYPE_CHECKING

from sqlalchemy import Integer, Text, Index, and_
from sqlalchemy.orm import relationship, Mapped, mapped_column, declared_attr, foreign

from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term

if TYPE_CHECKING:
pass


class Notes(Base, AutoBaseMixin, ReleaseMixin):
"""
Represents a single, categorized note that can be attached to various
parent objects throughout the database.
"""

# --- Polymorphic Columns ---
target_id: Mapped[int] = mapped_column(
Integer,
nullable=False,
comment="The ID of the parent record this note is about (e.g., a `thing_id`, `location_id`, etc).",
)
target_table: Mapped[str] = mapped_column()
# notable_type: Mapped[str] = lexicon_term(
# nullable=False,
# comment="The type of the note associated with this record.",
# )

# --- Columns ---
note_type: Mapped[str] = lexicon_term(
nullable=False,
comment="A controlled vocabulary field that defines the specific category of the note (e.g. 'Access Instructions`, ",
)
content: Mapped[str] = mapped_column(Text, nullable=False)

# --- Polymorphic Parent Relationships (Internal) ---
# These are viewonly relationships used by the 'target' property below.
# _thing_target: Mapped["Thing"] = relationship(
# "Thing",
# primaryjoin="and_(foreign(Notes.target_id) == Thing.id, Notes.target_table == 'thing')",
# viewonly=True,
# )
# _location_target: Mapped["Location"] = relationship(
# "Location",
# primaryjoin="and_(foreign(Notes.target_id) == Location.id, Notes.target_table == 'location')",
# viewonly=True,
# )

@property
def target(self):
"""
A generic property to get the parent object (Thing, Location, etc.).

This is useful for simplifying application code by providing a single,
consistent way to access the parent of a polymorphic record without
needing to check the 'notable_type' field manually.
"""
return getattr(self, f"_{self.target_table.lower()}_target")

# --- Table Arguments ---
# A composite index to optimize retrieval of all note records for a specific parent object.

__table_args__ = (Index("ix_notes_polymorphic_link", "target_id", "target_table"),)


class NotesMixin:
"""
Mixin for models that can have multiple types or categories of notes.
It automatically creates a polymorphic One-to-Many relationship to the
Notes table.
"""

@declared_attr
def notes(cls):
"""
The high-performance, declarative relationship for reading notes.
This provides a polymorphic one-to-many link to the Notes table.

PERFORMANCE NOTE: Use with `selectinload` in queries to prevent the
N+1 query problem when accessing notes for multiple parent objects.
"""
return relationship(
"Notes",
primaryjoin=and_(
cls.id == foreign(Notes.target_id),
Notes.target_table == cls.__name__,
),
cascade="all, delete-orphan",
lazy="selectin",
overlaps="notes",
)

def add_note(
self,
content: str,
note_type: str,
release_status: str = "draft",
created_by: str = None,
) -> Notes:
"""
A convenient factory method to create a new Note associated with this object.
This provides a clean, object-oriented API for writing.
"""

return Notes(
content=content,
note_type=note_type,
target_id=self.id,
target_table=self.__class__.__name__,
release_status=release_status,
)

def _get_notes(self, note_type: str) -> list[Notes]:
return [n for n in self.notes if n.note_type == note_type]
25 changes: 20 additions & 5 deletions db/thing.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from sqlalchemy.orm import relationship, mapped_column, Mapped
from sqlalchemy_utils import TSVectorType

from db import lexicon_term
from db import lexicon_term, NotesMixin
from db.asset import Asset
from db.base import (
AutoBaseMixin,
Expand All @@ -39,7 +39,9 @@
from db.group import Group, GroupThingAssociation


class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMixin):
class Thing(
Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMixin, NotesMixin
):
"""
Represents a physical object of interest being monitored (e.g., a well).
Stores static, core attributes of the physical installation.
Expand All @@ -52,9 +54,10 @@ class Thing(Base, AutoBaseMixin, ReleaseMixin, StatusHistoryMixin, PermissionMix
nullable=True,
comment="To audit where the data came from in NM_Aquifer if it was transferred over",
)
notes = mapped_column(Text, nullable=True)
measuring_notes = mapped_column(Text, nullable=True)
water_notes = mapped_column(Text, nullable=True)

# notes = mapped_column(Text, nullable=True)
# measuring_notes = mapped_column(Text, nullable=True)
# water_notes = mapped_column(Text, nullable=True)

# TODO: should `name` be unique?
name: Mapped[str] = mapped_column(
Expand Down Expand Up @@ -277,6 +280,18 @@ def current_location(self):
else None
)

@property
def water_notes(self):
return self._get_notes("Water")

@property
def general_notes(self):
return self._get_notes("Other")

@property
def measuring_notes(self):
return self._get_notes("Measuring")


class ThingIdLink(Base, AutoBaseMixin, ReleaseMixin):
"""
Expand Down
18 changes: 14 additions & 4 deletions schemas/location.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
from typing import List

from geoalchemy2 import WKBElement
from geoalchemy2.shape import to_shape
from pydantic import BaseModel, field_validator

from core.enums import ElevationMethod, CoordinateMethod
from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel
from schemas.notes import NoteResponse, CreateNote, UpdateNote
from services.validation.geospatial import validate_wkt_geometry


Expand All @@ -41,7 +44,10 @@ class CreateLocation(BaseCreateModel, ValidateLocation):
"""

# name: str | None = None
notes: str | None = None
# TODO: AI suggested managing notes via a separate /locations/{id}/notes endpoint.
# I don't know if we want to do that, but am leaving this comment for future reference.
# notes: str | None = None
notes: List[CreateNote] = []
point: str # point is required and should be in WKT format
elevation: float
elevation_accuracy: float | None = None
Expand All @@ -66,7 +72,9 @@ class LocationResponse(BaseResponseModel):
"""

# name: str | None
notes: str | None
# The 'notes' field is now a List of NoteResponse objects,
# matching the polymorphic relationship in the database model.
notes: List[NoteResponse] = []
point: str
elevation: float | None
horizontal_datum: str = "WGS84"
Expand Down Expand Up @@ -103,11 +111,13 @@ class GroupLocationResponse(BaseResponseModel):
# -------- UPDATE ----------
class UpdateLocation(BaseUpdateModel, ValidateLocation):
"""
Schema for updating a location.
Schema for updating a location. Notes are managed via the polymorphic Notes table.
"""

# name: str | None = None
notes: str | None = None
# TODO: AI suggested managing notes via a separate API endpoint, /notes/{note_id}.
# I don't know if we want to do that, but am leaving this comment for future reference.
notes: List[UpdateNote] = []
point: str | None = None
elevation: float | None = None
elevation_accuracy: float | None = None
Expand Down
46 changes: 46 additions & 0 deletions schemas/notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""
Pydantic models for the Notes table.
"""

from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel

# -------- BASE SCHEMA: ----------
"""
Defines the core, shared attributes of a Note for reuse.
"""


class BaseNote:
note_type: str
content: str


# -------- CREATE ----------
class CreateNote(BaseCreateModel, BaseNote):
# TODO: this was a suggestion by AI, but based on our other schemas it
# seems like more should be added here...
"""
Schema for creating a new Note. The parent object's ID and type will be
taken from the URL path, not the request body.
"""
pass
Comment thread
jirhiker marked this conversation as resolved.


# -------- RESPONSE ----------
class NoteResponse(BaseResponseModel, BaseNote):
"""
Response schema for Note details.
"""

target_id: int
target_table: str

Comment thread
jirhiker marked this conversation as resolved.

# -------- UPDATE ----------
class UpdateNote(BaseUpdateModel):
"""
Schema for updating an existing Note. All fields are optional
"""

note_type: str | None = None
content: str | None = None
Loading
Loading