From bc00372d95a333dc1dd7c414bb843313ffd0f4ad Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 30 Apr 2026 11:25:42 -0500 Subject: [PATCH 01/11] feat(thing_helper): Add contact name & type --- api/thing.py | 2 -- services/thing_helper.py | 50 +++++++++++++++++++++++++++++----------- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/api/thing.py b/api/thing.py index baeed59e..2a086486 100644 --- a/api/thing.py +++ b/api/thing.py @@ -154,7 +154,6 @@ def get_water_wells( filter_params: Annotated[list[str] | None, Query(alias="filter")] = None, query: Optional[str] = None, name: Optional[str] = None, - name_contains: Optional[str] = None, include_contacts: bool = False, ) -> CustomPage[WellResponse]: """ @@ -171,7 +170,6 @@ def get_water_wells( thing_type=thing_type, include_contacts=include_contacts, filters=filter_params, - name_contains=name_contains, ) diff --git a/services/thing_helper.py b/services/thing_helper.py index 16fdd9a6..3461c797 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -24,10 +24,12 @@ from pydantic import BaseModel from shapely import wkb from shapely.geometry import mapping -from sqlalchemy import select, func +from sqlalchemy import select, func, cast, Text, or_ +from sqlalchemy.dialects.postgresql import REGCONFIG from sqlalchemy.orm import Session, aliased, selectinload from starlette.status import HTTP_404_NOT_FOUND, HTTP_409_CONFLICT +from api.pagination import CustomPage from db import ( LocationThingAssociation, Thing, @@ -43,8 +45,9 @@ ThingIdLink, MonitoringFrequencyHistory, StatusHistory, - search, + Contact ) +from schemas.thing import WellResponse from services.audit_helper import audit_add from services.crud_helper import model_patcher from services.exceptions_helper import PydanticStyleException @@ -122,15 +125,37 @@ def get_db_things( name: Optional[str] = None, include_contacts: bool = False, filters: Optional[list[str]] = None, - name_contains: Optional[str] = None, -) -> list: - - if query: - sql = search( - select(Thing), - query, - vector=Thing.search_vector, - ) +) -> CustomPage[WellResponse]: + + if query and query.strip(): + search_term = f"%{query.strip()}%" + sql = select(Thing) + + if include_contacts: + sql = sql.options( + selectinload(Thing.contact_associations).selectinload( + ThingContactAssociation.contact + ) + ) + + sql = ( + sql.outerjoin(Thing.contact_associations) + .outerjoin(ThingContactAssociation.contact) + .where( + or_( + Thing.search_vector.op("@@")( + func.parse_websearch( + cast("english", REGCONFIG), + cast(query, Text), + ) + ), + Thing.name.ilike(search_term), + Thing.thing_type.ilike(search_term), + Contact.name.ilike(search_term), + ) + ) + .distinct(Thing.id) + ) else: sql = select(Thing) @@ -153,9 +178,6 @@ def get_db_things( if name: sql = sql.where(Thing.name == name) - if name_contains and name_contains.strip(): - sql = sql.where(Thing.name.ilike(f"%{name_contains.strip()}%")) - if within: latest_assoc = ( select( From 498496c60f4e8208b5aca7540e052364f80457d4 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 1 May 2026 10:55:04 -0500 Subject: [PATCH 02/11] feat(thing_helper): Add fuzzy searching queries --- services/thing_helper.py | 115 ++++++++++++++++++++++++++------------- 1 file changed, 77 insertions(+), 38 deletions(-) diff --git a/services/thing_helper.py b/services/thing_helper.py index 3461c797..ef1c830c 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -16,44 +16,44 @@ import logging import time from datetime import datetime -from typing import Sequence, Optional +from typing import Optional, Sequence from zoneinfo import ZoneInfo -from fastapi import Request, HTTPException +from fastapi import HTTPException, Request from fastapi_pagination.ext.sqlalchemy import paginate from pydantic import BaseModel from shapely import wkb from shapely.geometry import mapping -from sqlalchemy import select, func, cast, Text, or_ +from sqlalchemy import Text, cast, desc, func, or_, select from sqlalchemy.dialects.postgresql import REGCONFIG from sqlalchemy.orm import Session, aliased, selectinload from starlette.status import HTTP_404_NOT_FOUND, HTTP_409_CONFLICT from api.pagination import CustomPage from db import ( - LocationThingAssociation, - Thing, - ThingContactAssociation, - Location, - WellScreen, - WellPurpose, - WellCasingMaterial, - ThingAquiferAssociation, + Contact, + DataProvenance, GroupThingAssociation, + Location, + LocationThingAssociation, MeasuringPointHistory, - DataProvenance, - ThingIdLink, MonitoringFrequencyHistory, StatusHistory, - Contact + Thing, + ThingAquiferAssociation, + ThingContactAssociation, + ThingIdLink, + WellCasingMaterial, + WellPurpose, + WellScreen, ) from schemas.thing import WellResponse from services.audit_helper import audit_add from services.crud_helper import model_patcher -from services.exceptions_helper import PydanticStyleException from services.env import get_bool_env +from services.exceptions_helper import PydanticStyleException from services.geospatial_helper import make_within_wkt -from services.query_helper import make_query, order_sort_filter, simple_get_by_id +from services.query_helper import order_sort_filter, simple_get_by_id logger = logging.getLogger(__name__) @@ -127,36 +127,76 @@ def get_db_things( filters: Optional[list[str]] = None, ) -> CustomPage[WellResponse]: + # Querying logic + # + # We combine multiple search strategies: + # + # 1. Full-text search (tsvector) + # - Good for word-based and multi-word searches + # - Uses indexed search_vector column + # + # 2. Trigram fuzzy matching (% operator from pg_trgm) + # - Handles typos (e.g. "Aron" vs "Aaron") + # + # OR is used so any matching strategy can return a result. if query and query.strip(): - search_term = f"%{query.strip()}%" - sql = select(Thing) + clean_query = query.strip() + + # Similarity scores (used ONLY for ranking, not filtering) + # + # These use pg_trgm's similarity() to compute how close each field + # is to the search query. Higher = more similar. + name_sim = func.similarity(Thing.name, clean_query) + type_sim = func.similarity(Thing.thing_type, clean_query) + + search_conditions = [ + Thing.search_vector.op("@@")( + func.parse_websearch( + cast("english", REGCONFIG), + cast(clean_query, Text), + ) + ), + Thing.name.op("%")(clean_query), + Thing.thing_type.op("%")(clean_query), + ] + + rank_expressions = [ + name_sim, + type_sim, + ] if include_contacts: - sql = sql.options( - selectinload(Thing.contact_associations).selectinload( - ThingContactAssociation.contact - ) + contact_sim = func.coalesce(func.similarity(Contact.name, clean_query), 0) + + sql = sql.outerjoin(Thing.contact_associations).outerjoin( + ThingContactAssociation.contact ) + search_conditions.append(Contact.name.op("%")(clean_query)) + rank_expressions.append(contact_sim) + sql = ( - sql.outerjoin(Thing.contact_associations) - .outerjoin(ThingContactAssociation.contact) - .where( - or_( - Thing.search_vector.op("@@")( - func.parse_websearch( - cast("english", REGCONFIG), - cast(query, Text), - ) - ), - Thing.name.ilike(search_term), - Thing.thing_type.ilike(search_term), - Contact.name.ilike(search_term), - ) + sql.where(or_(*search_conditions)) + .order_by(desc(func.greatest(*rank_expressions))) + .distinct(Thing.id) + ) + + if thing_type: + sql = sql.where(Thing.thing_type == thing_type) + + if thing_type == WATER_WELL_THING_TYPE: + sql = sql.options(*WATER_WELL_LOADER_OPTIONS) + else: + sql = sql.options(*WATER_WELL_LOADER_OPTIONS) + + if include_contacts: + sql = sql.options( + selectinload(Thing.contact_associations).selectinload( + ThingContactAssociation.contact ) - .distinct(Thing.id) ) else: + # No search query → return base query (no filtering) sql = select(Thing) if thing_type: @@ -361,7 +401,6 @@ def add_thing( # ---------- if thing_type == WATER_WELL_THING_TYPE: - # Create MeasuringPointHistory record if measuring_point_height provided if measuring_point_height is not None: measuring_point_history = MeasuringPointHistory( From 399fc1a538a927cb43e14ec13529ee453673ae68 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 1 May 2026 11:06:21 -0500 Subject: [PATCH 03/11] fix(thing_helper): Rm duplicate thing_type filter --- api/thing.py | 25 +++++++++++++------------ services/thing_helper.py | 10 +--------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/api/thing.py b/api/thing.py index 2a086486..b2a9020b 100644 --- a/api/thing.py +++ b/api/thing.py @@ -14,6 +14,7 @@ # limitations under the License. # =============================================================================== from typing import Annotated, Optional + from fastapi import APIRouter, Query, Request from fastapi_pagination.ext.sqlalchemy import paginate from sqlalchemy import select @@ -29,47 +30,47 @@ from api.pagination import CustomPage from core.app import public_route from core.dependencies import ( - session_dependency, admin_dependency, editor_dependency, + session_dependency, viewer_dependency, ) from db.deployment import Deployment from db.thing import Thing, ThingIdLink, WellScreen from schemas.deployment import DeploymentResponse from schemas.thing import ( + CreateSpring, CreateThingIdLink, CreateWell, CreateWellScreen, - ThingResponse, - WellResponse, - WellScreenResponse, - UpdateSpring, - UpdateWell, SpringResponse, - CreateSpring, ThingIdLinkResponse, + ThingResponse, + UpdateSpring, UpdateThingIdLink, + UpdateWell, UpdateWellScreen, + WellResponse, + WellScreenResponse, ) from schemas.well_details import WellDetailsResponse from schemas.well_export import WellExportResponse -from services.crud_helper import model_patcher, model_adder, model_deleter +from services.crud_helper import model_adder, model_deleter, model_patcher from services.exceptions_helper import PydanticStyleException from services.lexicon_helper import get_terms_by_category from services.query_helper import ( - simple_get_by_id, - paginated_all_getter, order_sort_filter, + paginated_all_getter, + simple_get_by_id, ) from services.thing_helper import ( + WELL_DESCRIPTOR_MODEL_MAP, add_thing, - patch_thing, add_well_screen, get_db_things, get_thing_of_a_thing_type_by_id, modify_well_descriptor_tables, - WELL_DESCRIPTOR_MODEL_MAP, + patch_thing, ) from services.well_details_helper import ( get_well_details_payload, diff --git a/services/thing_helper.py b/services/thing_helper.py index ef1c830c..c00a5ed7 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -125,7 +125,7 @@ def get_db_things( name: Optional[str] = None, include_contacts: bool = False, filters: Optional[list[str]] = None, -) -> CustomPage[WellResponse]: +) -> list: # Querying logic # @@ -181,14 +181,6 @@ def get_db_things( .distinct(Thing.id) ) - if thing_type: - sql = sql.where(Thing.thing_type == thing_type) - - if thing_type == WATER_WELL_THING_TYPE: - sql = sql.options(*WATER_WELL_LOADER_OPTIONS) - else: - sql = sql.options(*WATER_WELL_LOADER_OPTIONS) - if include_contacts: sql = sql.options( selectinload(Thing.contact_associations).selectinload( From aa302115b93622f58c3517795daf517d41dacca3 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 1 May 2026 11:20:37 -0500 Subject: [PATCH 04/11] chore(alembic): Create migration to add & rm pg_trgm extension --- .../0493ebb8237d_enable_pg_trgm_extension.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 alembic/versions/0493ebb8237d_enable_pg_trgm_extension.py diff --git a/alembic/versions/0493ebb8237d_enable_pg_trgm_extension.py b/alembic/versions/0493ebb8237d_enable_pg_trgm_extension.py new file mode 100644 index 00000000..1f223eab --- /dev/null +++ b/alembic/versions/0493ebb8237d_enable_pg_trgm_extension.py @@ -0,0 +1,27 @@ +"""enable pg_trgm extension + +Revision ID: 0493ebb8237d +Revises: t6u7v8w9x0y1 +Create Date: 2026-05-01 11:17:44.571959 + +""" + +from typing import Sequence, Union + +from sqlalchemy import text + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0493ebb8237d" +down_revision: Union[str, Sequence[str], None] = "t6u7v8w9x0y1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm")) + + +def downgrade() -> None: + op.execute(text("DROP EXTENSION IF EXISTS pg_trgm")) From 64dc7bc6c5ffcd5a342a24e8479f29a4ab208bca Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 1 May 2026 11:33:49 -0500 Subject: [PATCH 05/11] fix(thing_helper): Patch borken sql varible & typing --- api/thing.py | 2 -- services/thing_helper.py | 8 +++----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/api/thing.py b/api/thing.py index b2a9020b..feda3ace 100644 --- a/api/thing.py +++ b/api/thing.py @@ -297,7 +297,6 @@ def get_springs( order: str = None, filter_params: Annotated[list[str] | None, Query(alias="filter")] = None, query: str = None, - name_contains: Optional[str] = None, ) -> CustomPage[SpringResponse]: """ Retrieve all springs from the database. @@ -311,7 +310,6 @@ def get_springs( sort, thing_type=thing_type, filters=filter_params, - name_contains=name_contains, ) diff --git a/services/thing_helper.py b/services/thing_helper.py index c00a5ed7..24dc5931 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -16,7 +16,7 @@ import logging import time from datetime import datetime -from typing import Optional, Sequence +from typing import Any, Optional, Sequence from zoneinfo import ZoneInfo from fastapi import HTTPException, Request @@ -125,7 +125,8 @@ def get_db_things( name: Optional[str] = None, include_contacts: bool = False, filters: Optional[list[str]] = None, -) -> list: +) -> CustomPage[Any]: + sql = select(Thing) # Querying logic # @@ -187,9 +188,6 @@ def get_db_things( ThingContactAssociation.contact ) ) - else: - # No search query → return base query (no filtering) - sql = select(Thing) if thing_type: sql = sql.where(Thing.thing_type == thing_type) From f30cce4c0a9269b2778d69b74419b533edfbe794 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 1 May 2026 11:39:41 -0500 Subject: [PATCH 06/11] fix(thing): Rm name_contains in the get things api --- api/thing.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/api/thing.py b/api/thing.py index feda3ace..f40b768c 100644 --- a/api/thing.py +++ b/api/thing.py @@ -370,7 +370,6 @@ def get_things( order: Optional[str] = None, include_contacts: bool = False, filter_params: Annotated[list[str] | None, Query(alias="filter")] = None, - name_contains: Optional[str] = None, ) -> CustomPage[ThingResponse]: """ Retrieve all things or filter by type. @@ -385,7 +384,6 @@ def get_things( within=within, include_contacts=include_contacts, filters=filter_params, - name_contains=name_contains, ) From 55525c42efbcf8d2115d62333d593ff53cf08d44 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 1 May 2026 12:31:05 -0500 Subject: [PATCH 07/11] fix(thing_helper): Add ilike back for partial searches --- db/engine.py | 8 ++--- pyproject.toml | 1 + services/thing_helper.py | 66 +++++++++++++++++++++++++++++----------- uv.lock | 48 +++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 22 deletions(-) diff --git a/db/engine.py b/db/engine.py index 2d2f0d9f..4983be93 100644 --- a/db/engine.py +++ b/db/engine.py @@ -179,7 +179,7 @@ def getconn(): conn = connector.connect( instance_name, # The Cloud SQL instance name - "pg8000", + "psycopg", **connect_kwargs, ) return conn @@ -190,7 +190,7 @@ def getconn(): pool_timeout = int(os.environ.get("DB_POOL_TIMEOUT", "30")) engine = create_engine( - "postgresql+pg8000://", + "postgresql+psycopg://", creator=getconn, echo=False, pool_size=pool_size, @@ -220,7 +220,7 @@ def getconn(): auth = f"{user}:{password}@" if user and password else "" port_part = f":{port}" if port else "" - url = f"postgresql+pg8000://{auth}{host}{port_part}/{name}" + url = f"postgresql+psycopg://{auth}{host}{port_part}/{name}" # else: # url = "sqlite:///./development.db" @@ -243,7 +243,7 @@ def getconn(): _install_pool_logging(engine) async_engine = create_async_engine( - url.replace("postgresql+pg8000", "postgresql+asyncpg"), + url.replace("postgresql+psycopg", "postgresql+asyncpg"), plugins=["geoalchemy2"], ) # if "postgresql" not in url: diff --git a/pyproject.toml b/pyproject.toml index e757a412..ff464cb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,7 @@ dependencies = [ "utm==0.8.1", "uvicorn==0.42.0", "yarl==1.23.0", + "psycopg[binary]>=3.3.3", ] [tool.uv] diff --git a/services/thing_helper.py b/services/thing_helper.py index 24dc5931..a04de468 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -130,25 +130,36 @@ def get_db_things( # Querying logic # - # We combine multiple search strategies: + # Combines multiple search strategies so the global search supports: # - # 1. Full-text search (tsvector) - # - Good for word-based and multi-word searches - # - Uses indexed search_vector column + # 1. Full-text search using Thing.search_vector + # - Best for normal word-based and multi-word searches. # - # 2. Trigram fuzzy matching (% operator from pg_trgm) - # - Handles typos (e.g. "Aron" vs "Aaron") + # 2. Trigram fuzzy search using pg_trgm operators + # - "%" compares similarity across the full string. + # - "<%" compares similarity against a word/substring extent, + # which helps with names like "cary" matching "john carry". + # + # 3. Partial substring search using ILIKE + # - Handles very short partial inputs like "ty" matching "tyler". + # - This is useful because trigram matching is less effective for + # short queries under 3 characters. # # OR is used so any matching strategy can return a result. if query and query.strip(): clean_query = query.strip() + partial_query = f"%{clean_query}%" - # Similarity scores (used ONLY for ranking, not filtering) + # Ranking scores # - # These use pg_trgm's similarity() to compute how close each field - # is to the search query. Higher = more similar. - name_sim = func.similarity(Thing.name, clean_query) - type_sim = func.similarity(Thing.thing_type, clean_query) + # These are used only for ordering results, not for filtering. + # similarity() ranks whole-string similarity. + # word_similarity() ranks best word/span similarity. + name_sim = func.coalesce(func.similarity(Thing.name, clean_query), 0) + type_sim = func.coalesce(func.similarity(Thing.thing_type, clean_query), 0) + + name_word_sim = func.coalesce(func.word_similarity(Thing.name, clean_query), 0) + type_word_sim = func.coalesce(func.word_similarity(Thing.thing_type, clean_query), 0) search_conditions = [ Thing.search_vector.op("@@")( @@ -159,27 +170,46 @@ def get_db_things( ), Thing.name.op("%")(clean_query), Thing.thing_type.op("%")(clean_query), + Thing.name.op("<%")(clean_query), + Thing.thing_type.op("<%")(clean_query), + Thing.name.ilike(partial_query), + Thing.thing_type.ilike(partial_query), ] rank_expressions = [ name_sim, type_sim, + name_word_sim, + type_word_sim, ] if include_contacts: contact_sim = func.coalesce(func.similarity(Contact.name, clean_query), 0) + contact_word_sim = func.coalesce( + func.word_similarity(clean_query, Contact.name), 0 + ) sql = sql.outerjoin(Thing.contact_associations).outerjoin( ThingContactAssociation.contact ) - search_conditions.append(Contact.name.op("%")(clean_query)) - rank_expressions.append(contact_sim) + search_conditions.extend( + [ + Contact.name.op("%")(clean_query), + Contact.name.op("<%")(clean_query), + Contact.name.ilike(partial_query), + ] + ) + + rank_expressions.extend( + [ + contact_sim, + contact_word_sim, + ] + ) - sql = ( - sql.where(or_(*search_conditions)) - .order_by(desc(func.greatest(*rank_expressions))) - .distinct(Thing.id) + sql = sql.where(or_(*search_conditions)).order_by( + desc(func.greatest(*rank_expressions)) ) if include_contacts: @@ -238,7 +268,7 @@ def get_db_things( sql = order_sort_filter(sql, Thing, sort, order, filters=merged_filters) - return paginate(query=sql, conn=session) + return paginate(query=sql, conn=session, unique=True) def get_thing_type_from_request(request: Request) -> str: diff --git a/uv.lock b/uv.lock index c0e13457..6b09914e 100644 --- a/uv.lock +++ b/uv.lock @@ -1503,6 +1503,7 @@ dependencies = [ { name = "propcache" }, { name = "proto-plus" }, { name = "protobuf" }, + { name = "psycopg", extra = ["binary"] }, { name = "psycopg2-binary" }, { name = "pyasn1" }, { name = "pyasn1-modules" }, @@ -1617,6 +1618,7 @@ requires-dist = [ { name = "propcache", specifier = "==0.4.1" }, { name = "proto-plus", specifier = "==1.27.2" }, { name = "protobuf", specifier = "==6.33.5" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.3.3" }, { name = "psycopg2-binary", specifier = ">=2.9.10" }, { name = "pyasn1", specifier = "==0.6.3" }, { name = "pyasn1-modules", specifier = "==0.4.2" }, @@ -2027,6 +2029,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "psycopg" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" }, + { url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" }, + { url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" }, + { url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" }, + { url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" }, + { url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" }, + { url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" }, + { url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" }, + { url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" }, +] + [[package]] name = "psycopg2-binary" version = "2.9.11" From 7ac4bf025a38ec31d7b30c92dece1dbf04c4cf60 Mon Sep 17 00:00:00 2001 From: TylerAdamMartinez <57375362+TylerAdamMartinez@users.noreply.github.com> Date: Fri, 1 May 2026 17:31:40 +0000 Subject: [PATCH 08/11] Formatting changes --- services/thing_helper.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/services/thing_helper.py b/services/thing_helper.py index a04de468..90610572 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -159,7 +159,9 @@ def get_db_things( type_sim = func.coalesce(func.similarity(Thing.thing_type, clean_query), 0) name_word_sim = func.coalesce(func.word_similarity(Thing.name, clean_query), 0) - type_word_sim = func.coalesce(func.word_similarity(Thing.thing_type, clean_query), 0) + type_word_sim = func.coalesce( + func.word_similarity(Thing.thing_type, clean_query), 0 + ) search_conditions = [ Thing.search_vector.op("@@")( @@ -200,7 +202,7 @@ def get_db_things( Contact.name.ilike(partial_query), ] ) - + rank_expressions.extend( [ contact_sim, From 3bae23ae7de493a8f4d458d8b9ecc5072c73f4e7 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 1 May 2026 12:39:47 -0500 Subject: [PATCH 09/11] fix(get_db_things): Made exact matches rank the highest --- services/thing_helper.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/services/thing_helper.py b/services/thing_helper.py index 90610572..ad3bf706 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -24,7 +24,7 @@ from pydantic import BaseModel from shapely import wkb from shapely.geometry import mapping -from sqlalchemy import Text, cast, desc, func, or_, select +from sqlalchemy import Text, case, cast, desc, func, or_, select from sqlalchemy.dialects.postgresql import REGCONFIG from sqlalchemy.orm import Session, aliased, selectinload from starlette.status import HTTP_404_NOT_FOUND, HTTP_409_CONFLICT @@ -163,6 +163,19 @@ def get_db_things( func.word_similarity(Thing.thing_type, clean_query), 0 ) + exact_or_partial_rank = case( + # Exact matches rank highest + (func.lower(Thing.name) == clean_query.lower(), 3.0), + (func.lower(Thing.thing_type) == clean_query.lower(), 3.0), + # Prefix matches rank next + (Thing.name.ilike(f"{clean_query}%"), 2.0), + (Thing.thing_type.ilike(f"{clean_query}%"), 2.0), + # Partial substring matches rank after prefix matches + (Thing.name.ilike(partial_query), 1.5), + (Thing.thing_type.ilike(partial_query), 1.5), + else_=0.0, + ) + search_conditions = [ Thing.search_vector.op("@@")( func.parse_websearch( @@ -179,6 +192,7 @@ def get_db_things( ] rank_expressions = [ + exact_or_partial_rank, name_sim, type_sim, name_word_sim, @@ -195,6 +209,13 @@ def get_db_things( ThingContactAssociation.contact ) + contact_exact_or_partial_rank = case( + (func.lower(Contact.name) == clean_query.lower(), 3.0), + (Contact.name.ilike(f"{clean_query}%"), 2.0), + (Contact.name.ilike(partial_query), 1.5), + else_=0.0, + ) + search_conditions.extend( [ Contact.name.op("%")(clean_query), @@ -205,6 +226,7 @@ def get_db_things( rank_expressions.extend( [ + contact_exact_or_partial_rank, contact_sim, contact_word_sim, ] From 24b7f7b4c11333a879714273c7d95c829e437884 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 4 May 2026 11:14:05 -0500 Subject: [PATCH 10/11] fix(thing_helper): Improve fuzzy searches --- services/thing_helper.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/services/thing_helper.py b/services/thing_helper.py index ad3bf706..79e5f698 100644 --- a/services/thing_helper.py +++ b/services/thing_helper.py @@ -24,7 +24,7 @@ from pydantic import BaseModel from shapely import wkb from shapely.geometry import mapping -from sqlalchemy import Text, case, cast, desc, func, or_, select +from sqlalchemy import Text, case, cast, desc, func, literal, or_, select from sqlalchemy.dialects.postgresql import REGCONFIG from sqlalchemy.orm import Session, aliased, selectinload from starlette.status import HTTP_404_NOT_FOUND, HTTP_409_CONFLICT @@ -165,11 +165,11 @@ def get_db_things( exact_or_partial_rank = case( # Exact matches rank highest - (func.lower(Thing.name) == clean_query.lower(), 3.0), - (func.lower(Thing.thing_type) == clean_query.lower(), 3.0), + (func.lower(Thing.name) == clean_query.lower(), 2.0), + (func.lower(Thing.thing_type) == clean_query.lower(), 2.0), # Prefix matches rank next - (Thing.name.ilike(f"{clean_query}%"), 2.0), - (Thing.thing_type.ilike(f"{clean_query}%"), 2.0), + (Thing.name.ilike(f"{clean_query}%"), 1.75), + (Thing.thing_type.ilike(f"{clean_query}%"), 1.75), # Partial substring matches rank after prefix matches (Thing.name.ilike(partial_query), 1.5), (Thing.thing_type.ilike(partial_query), 1.5), @@ -185,8 +185,10 @@ def get_db_things( ), Thing.name.op("%")(clean_query), Thing.thing_type.op("%")(clean_query), - Thing.name.op("<%")(clean_query), - Thing.thing_type.op("<%")(clean_query), + literal(clean_query).op("<%")(Thing.name), + literal(clean_query).op("<<%")(Thing.name), + literal(clean_query).op("<%")(Thing.thing_type), + literal(clean_query).op("<<%")(Thing.thing_type), Thing.name.ilike(partial_query), Thing.thing_type.ilike(partial_query), ] @@ -202,7 +204,7 @@ def get_db_things( if include_contacts: contact_sim = func.coalesce(func.similarity(Contact.name, clean_query), 0) contact_word_sim = func.coalesce( - func.word_similarity(clean_query, Contact.name), 0 + func.word_similarity(Contact.name, clean_query), 0 ) sql = sql.outerjoin(Thing.contact_associations).outerjoin( @@ -210,8 +212,8 @@ def get_db_things( ) contact_exact_or_partial_rank = case( - (func.lower(Contact.name) == clean_query.lower(), 3.0), - (Contact.name.ilike(f"{clean_query}%"), 2.0), + (func.lower(Contact.name) == clean_query.lower(), 2.0), + (Contact.name.ilike(f"{clean_query}%"), 1.75), (Contact.name.ilike(partial_query), 1.5), else_=0.0, ) @@ -219,7 +221,8 @@ def get_db_things( search_conditions.extend( [ Contact.name.op("%")(clean_query), - Contact.name.op("<%")(clean_query), + literal(clean_query).op("<%")(Contact.name), + literal(clean_query).op("<<%")(Contact.name), Contact.name.ilike(partial_query), ] ) From 4709d1307e207786c4549c6fd98b7907bc459e9c Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 4 May 2026 13:06:18 -0500 Subject: [PATCH 11/11] fix(api/asset): Add guard to check if thing exists --- api/asset.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/api/asset.py b/api/asset.py index 456b5d3a..4574747c 100644 --- a/api/asset.py +++ b/api/asset.py @@ -17,7 +17,7 @@ import logging import time -from fastapi import APIRouter, Depends, UploadFile, File +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi_pagination.ext.sqlalchemy import paginate from sqlalchemy import select from sqlalchemy.exc import ProgrammingError @@ -173,6 +173,20 @@ async def add_asset( assoc = AssetThingAssociation() audit_add(user, assoc) thing = session.get(Thing, thing_id) + + if not thing: + raise HTTPException( + status_code=409, + detail=[ + { + "loc": ["body", "thing_id"], + "msg": f"Thing with ID {thing_id} not found.", + "type": "value_error", + "input": {"thing_id": thing_id}, + } + ], + ) + assoc.thing = thing assoc.asset = asset session.add(assoc)