From bb1e6df3ed33c94482764a34a7acc178c70d7862 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:23:52 -0400 Subject: [PATCH 01/17] Add API routes to add and remove wells from projects. Supports the well edit panel in OcotilloUI (BDMS-879): POST and DELETE on /group/{group_id}/things/{thing_id} with 404/409 handling and audit on create. --- api/group.py | 62 ++++++++++++++++++++++---------------- services/group_helper.py | 64 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 26 deletions(-) diff --git a/api/group.py b/api/group.py index 962870c1..963c95ba 100644 --- a/api/group.py +++ b/api/group.py @@ -28,9 +28,11 @@ from schemas.group import UpdateGroup, CreateGroup, GroupResponse from services.crud_helper import model_patcher, model_deleter, model_adder from services.group_helper import ( + add_thing_to_group, get_well_counts_by_group_id, group_to_response, paginated_groups_getter, + remove_thing_from_group, ) from services.query_helper import simple_get_by_id @@ -49,21 +51,22 @@ def create_group( return model_adder(session, Group, group_data, user=user) -# @router.post( -# "/association", -# summary="Create a new group-thing association", -# status_code=status.HTTP_201_CREATED, -# ) -# def create_group_thing( -# group_location_data: CreateGroupThing, -# session: session_dependency, -# user: admin_dependency, -# ): -# """ -# Create a new group location association in the database. -# """ -# return adder(session, GroupThingAssociation, group_location_data, user=user) -# +@router.post( + "/{group_id}/things/{thing_id}", + summary="Add a thing to a group", + status_code=HTTP_201_CREATED, +) +def add_thing_to_group_route( + group_id: int, + thing_id: int, + session: session_dependency, + user: admin_dependency, +): + """ + Associate a thing (e.g. a water well) with a group (project). + Returns 409 if the association already exists. + """ + return add_thing_to_group(session, group_id, thing_id, user) # ============= Get ============================================= @@ -91,17 +94,6 @@ def get_group_by_id( return group_to_response(group, counts.get(group.id, 0)) -# @router.get( -# "/association/{association_id}", -# summary="Get group-thing association by ID", -# ) -# async def get_group_thing_by_id(association_id: int, session: session_dependency): -# """ -# Retrieve a group-thing association by ID from the database. -# """ -# return simple_get_by_id(session, GroupThingAssociation, association_id) - - # ============= Patch ============================================= @router.patch("/{group_id}", summary="Update a group by ID") def update_group( @@ -117,6 +109,24 @@ def update_group( # DELETE ======================================================================= +@router.delete( + "/{group_id}/things/{thing_id}", + summary="Remove a thing from a group", + status_code=HTTP_204_NO_CONTENT, +) +def remove_thing_from_group_route( + group_id: int, + thing_id: int, + session: session_dependency, + user: admin_dependency, +): + """ + Remove the association between a thing and a group. + Returns 404 if the association does not exist. + """ + remove_thing_from_group(session, group_id, thing_id) + + @router.delete( "/{group_id}", summary="Delete a group by ID", status_code=HTTP_204_NO_CONTENT ) diff --git a/services/group_helper.py b/services/group_helper.py index b81dd81c..9d73333e 100644 --- a/services/group_helper.py +++ b/services/group_helper.py @@ -15,13 +15,16 @@ # =============================================================================== from typing import Any +from fastapi import HTTPException from fastapi_pagination.ext.sqlalchemy import paginate from sqlalchemy import func, select from sqlalchemy.orm import Session +from starlette.status import HTTP_404_NOT_FOUND, HTTP_409_CONFLICT from db.group import Group, GroupThingAssociation from db.thing import Thing from schemas.group import GroupResponse +from services.audit_helper import audit_add from services.query_helper import order_sort_filter @@ -49,6 +52,67 @@ def group_to_response(group: Group, well_count: int = 0) -> GroupResponse: return response.model_copy(update={"well_count": well_count}) +def add_thing_to_group( + session: Session, group_id: int, thing_id: int, user: dict +) -> GroupThingAssociation: + group = session.get(Group, group_id) + if group is None: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail=f"Group with ID {group_id} not found.", + ) + + thing = session.get(Thing, thing_id) + if thing is None: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail=f"Thing with ID {thing_id} not found.", + ) + + existing = session.execute( + select(GroupThingAssociation).where( + GroupThingAssociation.group_id == group_id, + GroupThingAssociation.thing_id == thing_id, + ) + ).scalar_one_or_none() + + if existing is not None: + msg = f"Thing {thing_id} is already a member of group {group_id}." + raise HTTPException(status_code=HTTP_409_CONFLICT, detail=msg) + + assoc = GroupThingAssociation(group_id=group_id, thing_id=thing_id) + audit_add(user, assoc) + session.add(assoc) + session.commit() + session.refresh(assoc) + return assoc + + +def remove_thing_from_group( + session: Session, + group_id: int, + thing_id: int, +) -> None: + assoc = session.execute( + select(GroupThingAssociation).where( + GroupThingAssociation.group_id == group_id, + GroupThingAssociation.thing_id == thing_id, + ) + ).scalar_one_or_none() + + if assoc is None: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail=( + f"No association found between group {group_id} " + f"and thing {thing_id}." + ), + ) + + session.delete(assoc) + session.commit() + + def paginated_groups_getter( session: Session, filter_: str | None = None, From 31f1bcc42219772ac40d12598875eeb865c6d49e Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 02/17] Add shared helper for Slack edit notifications (BDMS-921). EditEvent model, Block Kit payload builder, and best-effort background webhook post when SLACK_EDITS_WEBHOOK_URL is set. --- services/edit_notification_helper.py | 217 +++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 services/edit_notification_helper.py diff --git a/services/edit_notification_helper.py b/services/edit_notification_helper.py new file mode 100644 index 00000000..3826cd1c --- /dev/null +++ b/services/edit_notification_helper.py @@ -0,0 +1,217 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from __future__ import annotations + +import logging +import os +import threading +from datetime import datetime, timezone +from typing import Any, Literal + +import httpx +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + +EditAction = Literal[ + "attachment_uploaded", + "project_added", + "project_removed", + "record_updated", + "record_created", + "record_deleted", +] + +NOTIFY_RESOURCE_TYPES = frozenset( + { + "well", + "spring", + "thing", + "contact", + "asset", + "group", + "location", + "sensor", + "sample", + } +) + +ACTION_HEADINGS: dict[EditAction, str] = { + "attachment_uploaded": "Attachment uploaded", + "project_added": "Project added", + "project_removed": "Project removed", + "record_updated": "Record updated", + "record_created": "Record created", + "record_deleted": "Record deleted", +} + +RESOURCE_UI_PATHS: dict[str, str] = { + "well": "ocotillo/well/show/{resource_id}", + "spring": "ocotillo/spring/show/{resource_id}", + "thing": "ocotillo/well/show/{resource_id}", + "contact": "ocotillo/contact/show/{resource_id}", + "group": "ocotillo/group/show/{resource_id}", + "asset": "ocotillo/asset/show/{resource_id}", + "location": "ocotillo/location/show/{resource_id}", + "sensor": "ocotillo/sensor/show/{resource_id}", + "sample": "ocotillo/sample/show/{resource_id}", +} + + +class EditEvent(BaseModel): + action: EditAction + resource_type: str + resource_id: int | str + resource_label: str + summary: str + field_changes: dict[str, dict[str, Any]] | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +def format_file_size(size_bytes: int) -> str: + if size_bytes < 1024: + return f"{size_bytes} B" + if size_bytes < 1024**2: + return f"{size_bytes / 1024:.1f} KB" + return f"{size_bytes / (1024**2):.1f} MB" + + +def environment_label(environment: str | None = None) -> str: + raw = environment or os.environ.get("ENVIRONMENT", "unknown") + env = raw.strip().lower() + if env == "production": + return "PRODUCTION" + if env == "staging": + return "STAGING" + return env.upper() or "UNKNOWN" + + +def build_record_url(resource_type: str, resource_id: int | str) -> str | None: + base = (os.environ.get("OCOTILLO_UI_BASE_URL") or "").strip().rstrip("/") + if not base: + return None + + path_template = RESOURCE_UI_PATHS.get(resource_type) + if not path_template: + return None + + return f"{base}/{path_template.format(resource_id=resource_id)}" + + +def format_field_changes( + field_changes: dict[str, dict[str, Any]] | None, +) -> str: + if not field_changes: + return "" + + lines: list[str] = [] + for field, change in field_changes.items(): + before = _format_display_value(change.get("before")) + after = _format_display_value(change.get("after")) + lines.append(f"{field}: {before} → {after}") + return "\n".join(lines) + + +def build_slack_payload( + event: EditEvent, + user: dict[str, Any], + environment: str | None = None, +) -> dict[str, Any]: + env_label = environment_label(environment) + heading_action = ACTION_HEADINGS.get(event.action, event.action) + header = f"[{env_label}] {heading_action} — {event.resource_label}" + + actor_name = ( + user.get("name") or user.get("preferred_username") or "Unknown" + ) + actor_email = user.get("email") + who = actor_name if not actor_email else f"{actor_name} ({actor_email})" + when = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + + fields: list[dict[str, str]] = [ + {"type": "mrkdwn", "text": f"*Who:*\n{who}"}, + {"type": "mrkdwn", "text": f"*When:*\n{when}"}, + {"type": "mrkdwn", "text": f"*What:*\n{event.summary}"}, + ] + + diff_text = format_field_changes(event.field_changes) + if diff_text: + fields.append({"type": "mrkdwn", "text": f"*Changes:*\n{diff_text}"}) + + header_block = { + "type": "header", + "text": {"type": "plain_text", "text": header[:150]}, + } + blocks: list[dict[str, Any]] = [ + header_block, + {"type": "section", "fields": fields[:10]}, + ] + + record_url = build_record_url(event.resource_type, event.resource_id) + if record_url: + blocks.append( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"<{record_url}|View in Ocotillo →>", + }, + } + ) + + return {"text": header, "blocks": blocks} + + +def notify_edit_event(user: Any, event: EditEvent) -> None: + if not isinstance(user, dict): + return + + webhook = os.environ.get("SLACK_EDITS_WEBHOOK_URL") + if not webhook: + return + + if event.resource_type not in NOTIFY_RESOURCE_TYPES: + return + + payload = build_slack_payload(event, user) + _post_slack_async(webhook, payload) + + +def _post_slack_async(webhook_url: str, payload: dict[str, Any]) -> None: + def _send() -> None: + try: + httpx.post(webhook_url, json=payload, timeout=10.0) + except Exception: + logger.warning( + "Slack edit notification failed", + exc_info=True, + ) + + threading.Thread(target=_send, daemon=True).start() + + +def _format_display_value(value: Any) -> str: + if value is None: + return "N/A" + if isinstance(value, str) and not value.strip(): + return "N/A" + text = str(value) + if len(text) > 200: + return f"{text[:197]}..." + return text + + +# ============= EOF ============================================= From bf052983f2a615e9449212eb33b9513061e595ae Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 03/17] Document SLACK_EDITS_WEBHOOK_URL and OCOTILLO_UI_BASE_URL in .env.example. --- .env.example | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.env.example b/.env.example index 1645fa31..e6ead732 100644 --- a/.env.example +++ b/.env.example @@ -77,3 +77,11 @@ JIRA_API_TOKEN=your_jira_api_token JIRA_DEFAULT_PROJECT=BDMS # Optional — Slack notifications are skipped if this is blank SLACK_FEEDBACK_WEBHOOK_URL= + +# Edit notifications (Slack) — POST/PATCH/DELETE mutations and uploads +# Optional — notifications are skipped if this is blank +SLACK_EDITS_WEBHOOK_URL= +# Base URL for deep links in Slack messages (no trailing slash) +# Staging: https://ocotillo-staging.newmexicowaterdata.org +# Production: https://ocotillo.newmexicowaterdata.org +OCOTILLO_UI_BASE_URL= From 58d8dd365d1228114d2c849c01406c92dec54028 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 04/17] Pass edit-notification env vars through the App Engine deploy template. --- .github/app.template.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/app.template.yaml b/.github/app.template.yaml index d3eb23ab..3abdacb6 100644 --- a/.github/app.template.yaml +++ b/.github/app.template.yaml @@ -45,3 +45,6 @@ env_variables: JIRA_DEFAULT_PROJECT: "${JIRA_DEFAULT_PROJECT}" SLACK_FEEDBACK_WEBHOOK_URL: |- ${SLACK_FEEDBACK_WEBHOOK_URL} + SLACK_EDITS_WEBHOOK_URL: |- + ${SLACK_EDITS_WEBHOOK_URL} + OCOTILLO_UI_BASE_URL: "${OCOTILLO_UI_BASE_URL}" From 1b1def2c160a2d37d4337b1c26a4f40dd11fe733 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 05/17] Notify Slack when a well is added to or removed from a project. --- services/group_helper.py | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/services/group_helper.py b/services/group_helper.py index 9d73333e..8fa50140 100644 --- a/services/group_helper.py +++ b/services/group_helper.py @@ -25,9 +25,18 @@ from db.thing import Thing from schemas.group import GroupResponse from services.audit_helper import audit_add +from services.edit_notification_helper import EditEvent, notify_edit_event from services.query_helper import order_sort_filter +def _thing_resource_type(thing: Thing) -> str: + if thing.thing_type == "water well": + return "well" + if thing.thing_type == "spring": + return "spring" + return "thing" + + def get_well_counts_by_group_id( session: Session, group_ids: list[int] ) -> dict[int, int]: @@ -85,6 +94,20 @@ def add_thing_to_group( session.add(assoc) session.commit() session.refresh(assoc) + + thing_label = thing.name or f"Thing {thing_id}" + group_name = group.name or f"Group {group_id}" + notify_edit_event( + user, + EditEvent( + action="project_added", + resource_type=_thing_resource_type(thing), + resource_id=thing_id, + resource_label=thing_label, + summary=f'Added {thing_label} to project "{group_name}"', + metadata={"group_id": group_id, "group_name": group_name}, + ), + ) return assoc @@ -92,7 +115,11 @@ def remove_thing_from_group( session: Session, group_id: int, thing_id: int, + user: dict | None = None, ) -> None: + group = session.get(Group, group_id) + thing = session.get(Thing, thing_id) + assoc = session.execute( select(GroupThingAssociation).where( GroupThingAssociation.group_id == group_id, @@ -112,6 +139,21 @@ def remove_thing_from_group( session.delete(assoc) session.commit() + if user and thing is not None: + thing_label = thing.name or f"Thing {thing_id}" + group_name = (group.name if group else None) or f"Group {group_id}" + notify_edit_event( + user, + EditEvent( + action="project_removed", + resource_type=_thing_resource_type(thing), + resource_id=thing_id, + resource_label=thing_label, + summary=f'Removed {thing_label} from project "{group_name}"', + metadata={"group_id": group_id, "group_name": group_name}, + ), + ) + def paginated_groups_getter( session: Session, From 7ca550ff5e1b4387ede8b9d6fba89809497eb854 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 06/17] Pass the authenticated user into group delete and thing-removal routes for edit notifications. --- api/group.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/group.py b/api/group.py index 963c95ba..98ee3abc 100644 --- a/api/group.py +++ b/api/group.py @@ -124,14 +124,14 @@ def remove_thing_from_group_route( Remove the association between a thing and a group. Returns 404 if the association does not exist. """ - remove_thing_from_group(session, group_id, thing_id) + remove_thing_from_group(session, group_id, thing_id, user=user) @router.delete( "/{group_id}", summary="Delete a group by ID", status_code=HTTP_204_NO_CONTENT ) def delete_group(user: admin_dependency, group_id: int, session: session_dependency): - return model_deleter(session, Group, group_id) + return model_deleter(session, Group, group_id, user=user) # ============= EOF ============================================= From eaf3e44c56e5fd7ab4ad34af3e6efb9338988409 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 07/17] Notify Slack after a new asset upload on upload-and-record, not on duplicates. --- api/asset.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/api/asset.py b/api/asset.py index f56f3f3d..32fa8858 100644 --- a/api/asset.py +++ b/api/asset.py @@ -41,6 +41,11 @@ from schemas.asset import AssetResponse, CreateAsset, UpdateAsset from services.audit_helper import audit_add from services.crud_helper import model_patcher, model_deleter +from services.edit_notification_helper import ( + EditEvent, + format_file_size, + notify_edit_event, +) from services.env import get_bool_env from services.exceptions_helper import PydanticStyleException from services.query_helper import simple_get_by_id @@ -345,6 +350,29 @@ async def upload_and_record_asset( raise session.refresh(asset) + + thing_label = thing.name or f"Thing {thing_id}" + file_name = asset.name or file.filename or "attachment" + + notify_edit_event( + user, + EditEvent( + action="attachment_uploaded", + resource_type="well" if thing.thing_type == "water well" else "thing", + resource_id=thing_id, + resource_label=thing_label, + summary=( + f"Uploaded {file_name} ({asset.mime_type}, " + f"{format_file_size(asset.size or file_size)}) to {thing_label}" + ), + metadata={ + "file_name": file_name, + "mime_type": asset.mime_type, + "size": asset.size or file_size, + "asset_id": asset.id, + }, + ), + ) return asset From ef49b0fd01664d650e59df3220140c8c149d822f Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 08/17] Emit Slack edit notifications from generic create, update, and delete helpers. --- services/crud_helper.py | 131 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 3 deletions(-) diff --git a/services/crud_helper.py b/services/crud_helper.py index 01eaeb25..dcc0e087 100644 --- a/services/crud_helper.py +++ b/services/crud_helper.py @@ -13,14 +13,26 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== +from typing import Any + from fastapi import Response from pydantic import BaseModel -from sqlalchemy.orm import Session, DeclarativeBase +from sqlalchemy.orm import DeclarativeBase, Session from starlette.status import HTTP_204_NO_CONTENT from db.notes import NotesMixin +from services.edit_notification_helper import EditEvent, notify_edit_event from services.query_helper import simple_get_by_id +TABLE_RESOURCE_TYPES: dict[str, str] = { + "contact": "contact", + "group": "group", + "asset": "asset", + "location": "location", + "sensor": "sensor", + "sample": "sample", +} + def model_adder(session, table, model, user=None, **kwargs): """ @@ -53,6 +65,22 @@ def model_adder(session, table, model, user=None, **kwargs): session.commit() session.refresh(obj) + + if user: + resource_type = _resource_type_for_item(table, obj) + if resource_type: + label = _resource_label(obj) + notify_edit_event( + user, + EditEvent( + action="record_created", + resource_type=resource_type, + resource_id=obj.id, + resource_label=label, + summary=f"Created {resource_type} {label}", + ), + ) + return obj @@ -72,8 +100,10 @@ def model_patcher( exclude_unset ensures that fields that are not set in the payload do not update record fields to None """ + updates = payload.model_dump(exclude_unset=True) + before = _snapshot_field_values(item, updates.keys()) - for key, value in payload.model_dump(exclude_unset=True).items(): + for key, value in updates.items(): if isinstance(item, NotesMixin) and key == "notes": # delete all notes and re-add for note in item.notes: @@ -91,15 +121,110 @@ def model_patcher( session.commit() session.refresh(item) + + if user: + resource_type = _resource_type_for_item(model, item) + if resource_type: + label = _resource_label(item) + field_changes = _compute_field_changes( + before, item, updates.keys() + ) + summary = f"Updated {resource_type} {label}" + notify_edit_event( + user, + EditEvent( + action="record_updated", + resource_type=resource_type, + resource_id=item.id, + resource_label=label, + summary=summary, + field_changes=field_changes or None, + ), + ) + return item -def model_deleter(session: Session, model: DeclarativeBase, item_id: int): +def model_deleter( + session: Session, + model: DeclarativeBase, + item_id: int, + user: dict | None = None, +): # simple_get_by_id raises HTTP_404_NOT_FOUND if the item is not found item = simple_get_by_id(session, model, item_id) + resource_type = _resource_type_for_item(model, item) + label = _resource_label(item) + item_id_value = item.id + session.delete(item) session.commit() + + if user and resource_type: + notify_edit_event( + user, + EditEvent( + action="record_deleted", + resource_type=resource_type, + resource_id=item_id_value, + resource_label=label, + summary=f"Deleted {resource_type} {label}", + ), + ) + return Response(status_code=HTTP_204_NO_CONTENT) +def _resource_type_for_item(model: type, item: Any) -> str | None: + table = getattr(model, "__tablename__", None) + if table == "thing": + thing_type = getattr(item, "thing_type", None) + if thing_type == "water well": + return "well" + if thing_type == "spring": + return "spring" + return "thing" + return TABLE_RESOURCE_TYPES.get(table or "") + + +def _resource_label(item: Any) -> str: + for attr in ("name", "label", "title", "site_name"): + value = getattr(item, attr, None) + if value: + return str(value) + item_id = getattr(item, "id", None) + return f"ID {item_id}" if item_id is not None else "record" + + +def _snapshot_field_values(item: Any, keys: Any) -> dict[str, Any]: + return { + key: _serialize_field_value(getattr(item, key, None)) for key in keys + } + + +def _compute_field_changes( + before: dict[str, Any], + item: Any, + keys: Any, +) -> dict[str, dict[str, Any]]: + changes: dict[str, dict[str, Any]] = {} + for key in keys: + after_value = _serialize_field_value(getattr(item, key, None)) + if before.get(key) != after_value: + changes[key] = {"before": before.get(key), "after": after_value} + return changes + + +def _serialize_field_value(value: Any) -> Any: + if value is None: + return None + if hasattr(value, "isoformat"): + return value.isoformat() + if hasattr(value, "wkt"): + return value.wkt + if isinstance(value, (list, dict)): + return value + return value + + # ============= EOF ============================================= From c294d174f993871ae1c786478b8aed2ca6153eaf Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 09/17] Add unit tests for edit notification payload building and notify behavior. --- tests/test_edit_notification_helper.py | 145 +++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 tests/test_edit_notification_helper.py diff --git a/tests/test_edit_notification_helper.py b/tests/test_edit_notification_helper.py new file mode 100644 index 00000000..2a8d45b4 --- /dev/null +++ b/tests/test_edit_notification_helper.py @@ -0,0 +1,145 @@ +import pytest + +from services.edit_notification_helper import ( + EditEvent, + build_record_url, + build_slack_payload, + environment_label, + format_field_changes, + format_file_size, + notify_edit_event, +) + + +@pytest.fixture +def slack_capture(monkeypatch): + calls: list[tuple[str, dict]] = [] + + def _capture(webhook_url: str, payload: dict) -> None: + calls.append((webhook_url, payload)) + + monkeypatch.setenv("SLACK_EDITS_WEBHOOK_URL", "https://hooks.slack.test/edit") + monkeypatch.setenv("OCOTILLO_UI_BASE_URL", "https://ocotillo.example.org") + monkeypatch.setattr( + "services.edit_notification_helper._post_slack_async", + _capture, + ) + return calls + + +def test_environment_label(): + assert environment_label("staging") == "STAGING" + assert environment_label("production") == "PRODUCTION" + assert environment_label("dev") == "DEV" + + +def test_format_file_size(): + assert format_file_size(512) == "512 B" + assert format_file_size(2048) == "2.0 KB" + assert format_file_size(2 * 1024 * 1024) == "2.0 MB" + + +def test_build_record_url(monkeypatch): + monkeypatch.setenv("OCOTILLO_UI_BASE_URL", "https://ocotillo.example.org") + assert ( + build_record_url("well", 42) + == "https://ocotillo.example.org/ocotillo/well/show/42" + ) + assert build_record_url("unknown", 1) is None + + +def test_build_slack_payload_includes_environment_and_diffs(monkeypatch): + monkeypatch.setenv("OCOTILLO_UI_BASE_URL", "https://ocotillo.example.org") + event = EditEvent( + action="record_updated", + resource_type="contact", + resource_id=7, + resource_label="Jane Doe", + summary="Updated contact Jane Doe", + field_changes={ + "phone": {"before": "505-555-1234", "after": "505-555-5678"}, + }, + ) + user = {"name": "Jeremy Zilar", "email": "jeremy@example.org"} + + payload = build_slack_payload(event, user, environment="staging") + header = payload["blocks"][0]["text"]["text"] + + assert header.startswith("[STAGING] Record updated — Jane Doe") + assert payload["blocks"][1]["fields"][0]["text"].startswith("*Who:*") + assert "505-555-1234" in payload["blocks"][1]["fields"][3]["text"] + assert "View in Ocotillo" in payload["blocks"][2]["text"]["text"] + + +def test_format_field_changes_empty(): + assert format_field_changes(None) == "" + assert format_field_changes({}) == "" + + +def test_build_slack_payload_attachment_upload(): + event = EditEvent( + action="attachment_uploaded", + resource_type="well", + resource_id=28251, + resource_label="NM-28251", + summary=( + "Uploaded construction_log.pdf (application/pdf, 1.2 MB) " "to NM-28251" + ), + ) + payload = build_slack_payload( + event, + {"name": "Tyler Smith", "email": "tyler@example.org"}, + environment="production", + ) + + assert payload["blocks"][0]["text"]["text"].startswith( + "[PRODUCTION] Attachment uploaded — NM-28251" + ) + + +def test_notify_edit_event_skips_without_webhook(monkeypatch, slack_capture): + monkeypatch.delenv("SLACK_EDITS_WEBHOOK_URL", raising=False) + notify_edit_event( + {"name": "Test User"}, + EditEvent( + action="project_added", + resource_type="well", + resource_id=1, + resource_label="NM-1", + summary='Added NM-1 to project "Demo"', + ), + ) + assert slack_capture == [] + + +def test_notify_edit_event_skips_non_dict_user(slack_capture): + notify_edit_event( + True, + EditEvent( + action="project_added", + resource_type="well", + resource_id=1, + resource_label="NM-1", + summary='Added NM-1 to project "Demo"', + ), + ) + assert slack_capture == [] + + +def test_notify_edit_event_posts_payload(slack_capture): + notify_edit_event( + {"name": "Test User", "email": "test@example.org"}, + EditEvent( + action="project_removed", + resource_type="well", + resource_id=99, + resource_label="NM-99", + summary='Removed NM-99 from project "Demo"', + ), + ) + + assert len(slack_capture) == 1 + webhook, payload = slack_capture[0] + assert webhook == "https://hooks.slack.test/edit" + assert "project_removed" not in payload["text"] + assert "NM-99" in payload["text"] From ebfb11cc1edee49b99b0fec62763d2ae54fe1bb3 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 10/17] Add group thing association route tests and Slack notification coverage. --- tests/test_group.py | 96 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/tests/test_group.py b/tests/test_group.py index de4c6672..a973c987 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -230,3 +230,99 @@ def test_delete_group_404_not_found(second_group): assert response.status_code == 404 data = response.json() assert data["detail"] == f"Group with ID {bad_id} not found." + + +# GROUP-THING association tests ================================================ + + +def test_add_thing_to_group_route(spring_thing): + payload = { + "release_status": "private", + "name": "Slack Notify Test Group", + "description": "Temporary group for association test.", + } + create_response = client.post("/group", json=payload) + assert create_response.status_code == 201 + group_id = create_response.json()["id"] + + response = client.post(f"/group/{group_id}/things/{spring_thing.id}") + assert response.status_code == 201 + data = response.json() + assert data["group_id"] == group_id + assert data["thing_id"] == spring_thing.id + + cleanup_post_test(GroupThingAssociation, data["id"]) + cleanup_post_test(Group, group_id) + + +def test_add_thing_to_group_route_409_duplicate(group, water_well_thing): + response = client.post(f"/group/{group.id}/things/{water_well_thing.id}") + assert response.status_code == 409 + + +def test_remove_thing_from_group_route(group, water_well_thing): + response = client.delete(f"/group/{group.id}/things/{water_well_thing.id}") + assert response.status_code == 204 + + # restore association for other tests using this fixture + with session_ctx() as session: + session.add( + GroupThingAssociation(group_id=group.id, thing_id=water_well_thing.id) + ) + session.commit() + + +def test_add_thing_to_group_notifies_slack(spring_thing, monkeypatch): + calls: list[tuple[str, dict]] = [] + + def _capture(webhook_url: str, payload: dict) -> None: + calls.append((webhook_url, payload)) + + monkeypatch.setenv("SLACK_EDITS_WEBHOOK_URL", "https://hooks.slack.test/edit") + monkeypatch.setattr( + "services.edit_notification_helper._post_slack_async", + _capture, + ) + + payload = { + "release_status": "private", + "name": "Slack Association Group", + "description": "Temporary group for Slack test.", + } + create_response = client.post("/group", json=payload) + group_id = create_response.json()["id"] + + response = client.post(f"/group/{group_id}/things/{spring_thing.id}") + assert response.status_code == 201 + assoc_id = response.json()["id"] + + project_calls = [call for call in calls if "Project added" in call[1]["text"]] + assert len(project_calls) == 1 + assert spring_thing.name in project_calls[0][1]["text"] + + cleanup_post_test(GroupThingAssociation, assoc_id) + cleanup_post_test(Group, group_id) + + +def test_remove_thing_from_group_notifies_slack(group, water_well_thing, monkeypatch): + calls: list[tuple[str, dict]] = [] + + def _capture(webhook_url: str, payload: dict) -> None: + calls.append((webhook_url, payload)) + + monkeypatch.setenv("SLACK_EDITS_WEBHOOK_URL", "https://hooks.slack.test/edit") + monkeypatch.setattr( + "services.edit_notification_helper._post_slack_async", + _capture, + ) + + response = client.delete(f"/group/{group.id}/things/{water_well_thing.id}") + assert response.status_code == 204 + assert len(calls) == 1 + assert water_well_thing.name in calls[0][1]["text"] + + with session_ctx() as session: + session.add( + GroupThingAssociation(group_id=group.id, thing_id=water_well_thing.id) + ) + session.commit() From 5cd7f75c71961839f494f8b5cc331414465b61b3 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 11/17] Add upload-and-record Slack notification tests, including duplicate silence. --- tests/test_asset.py | 70 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/test_asset.py b/tests/test_asset.py index d7ee0289..533cd560 100644 --- a/tests/test_asset.py +++ b/tests/test_asset.py @@ -452,6 +452,76 @@ def test_upload_and_record_asset_duplicate_returns_existing(water_well_thing): cleanup_post_test(Asset, first_id) +def test_upload_and_record_asset_notifies_slack(water_well_thing, monkeypatch): + calls: list[tuple[str, dict]] = [] + + def _capture(webhook_url: str, payload: dict) -> None: + calls.append((webhook_url, payload)) + + monkeypatch.setenv("SLACK_EDITS_WEBHOOK_URL", "https://hooks.slack.test/edit") + monkeypatch.setattr( + "services.edit_notification_helper._post_slack_async", + _capture, + ) + + path = "tests/data/riochama.png" + with open(path, "rb") as f: + response = client.post( + "/asset/upload-and-record", + data={"thing_id": water_well_thing.id, "label": "Slack test photo"}, + files={"file": ("slack-test.png", f, "image/png")}, + ) + + assert response.status_code == 201 + assert len(calls) == 1 + payload = calls[0][1] + assert water_well_thing.name in payload["text"] + what_field = next( + field + for field in payload["blocks"][1]["fields"] + if field["text"].startswith("*What:*") + ) + assert "slack-test.png" in what_field["text"] + + cleanup_post_test(Asset, response.json()["id"]) + + +def test_upload_and_record_asset_duplicate_does_not_notify_slack( + water_well_thing, monkeypatch +): + calls: list[tuple[str, dict]] = [] + + def _capture(webhook_url: str, payload: dict) -> None: + calls.append((webhook_url, payload)) + + monkeypatch.setenv("SLACK_EDITS_WEBHOOK_URL", "https://hooks.slack.test/edit") + monkeypatch.setattr( + "services.edit_notification_helper._post_slack_async", + _capture, + ) + + path = "tests/data/riochama.png" + with open(path, "rb") as f: + first = client.post( + "/asset/upload-and-record", + data={"thing_id": water_well_thing.id}, + files={"file": ("riochama.png", f, "image/png")}, + ) + assert first.status_code == 201 + assert len(calls) == 1 + + with open(path, "rb") as f: + second = client.post( + "/asset/upload-and-record", + data={"thing_id": water_well_thing.id}, + files={"file": ("riochama.png", f, "image/png")}, + ) + assert second.status_code == 201 + assert len(calls) == 1 + + cleanup_post_test(Asset, first.json()["id"]) + + def test_upload_and_record_asset_bad_thing_id(): """ Providing a thing_id that does not exist must return 409 Conflict. From 23dd102bab451098a3c32f75697ddff7f7ac2c48 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 12/17] Wire edit-notification secrets and UI base URL into staging deploy workflow. --- .github/workflows/CD_staging.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index e55c6f2a..8415c34e 100644 --- a/.github/workflows/CD_staging.yml +++ b/.github/workflows/CD_staging.yml @@ -47,6 +47,7 @@ jobs: jira_email:${{ vars.GCP_PROJECT_ID }}/jira-email jira_api_token:${{ vars.GCP_PROJECT_ID }}/jira-api-token slack_feedback_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-feedback-webhook-url + slack_edits_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-edits-webhook-url - name: Run Alembic migrations on staging database env: @@ -102,6 +103,8 @@ jobs: JIRA_API_TOKEN: "${{ steps.feedback-secrets.outputs.jira_api_token }}" JIRA_DEFAULT_PROJECT: "${{ vars.JIRA_DEFAULT_PROJECT || 'BDMS' }}" SLACK_FEEDBACK_WEBHOOK_URL: "${{ steps.feedback-secrets.outputs.slack_feedback_webhook_url }}" + SLACK_EDITS_WEBHOOK_URL: "${{ steps.feedback-secrets.outputs.slack_edits_webhook_url }}" + OCOTILLO_UI_BASE_URL: "${{ vars.OCOTILLO_UI_BASE_URL || 'https://ocotillo-staging.newmexicowaterdata.org' }}" run: | export MAX_INSTANCES="10" export SERVICE_NAME="ocotillo-api-staging" From cdb6a01324c137fac0269ab5cb0887e11514c595 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 13/17] Wire edit-notification secrets and UI base URL into production deploy workflow. --- .github/workflows/CD_production.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index 1ade7f25..e7a3fba8 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -82,6 +82,7 @@ jobs: jira_email:${{ vars.GCP_PROJECT_ID }}/jira-email jira_api_token:${{ vars.GCP_PROJECT_ID }}/jira-api-token slack_feedback_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-feedback-webhook-url + slack_edits_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-edits-webhook-url - name: Run Alembic migrations on production database env: @@ -142,6 +143,8 @@ jobs: JIRA_API_TOKEN: "${{ steps.feedback-secrets.outputs.jira_api_token }}" JIRA_DEFAULT_PROJECT: "${{ vars.JIRA_DEFAULT_PROJECT || 'BDMS' }}" SLACK_FEEDBACK_WEBHOOK_URL: "${{ steps.feedback-secrets.outputs.slack_feedback_webhook_url }}" + SLACK_EDITS_WEBHOOK_URL: "${{ steps.feedback-secrets.outputs.slack_edits_webhook_url }}" + OCOTILLO_UI_BASE_URL: "${{ vars.OCOTILLO_UI_BASE_URL || 'https://ocotillo.newmexicowaterdata.org' }}" run: | export MAX_INSTANCES="10" export SERVICE_NAME="ocotillo-api" From 6ad76175e848029041faa4aef0f7ae20506a8544 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 14/17] Wire edit-notification secrets and UI base URL into testing deploy workflow. --- .github/workflows/CD_testing.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/CD_testing.yml b/.github/workflows/CD_testing.yml index 64e15443..32e1e34c 100644 --- a/.github/workflows/CD_testing.yml +++ b/.github/workflows/CD_testing.yml @@ -47,6 +47,7 @@ jobs: jira_email:${{ vars.GCP_PROJECT_ID }}/jira-email jira_api_token:${{ vars.GCP_PROJECT_ID }}/jira-api-token slack_feedback_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-feedback-webhook-url + slack_edits_webhook_url:${{ vars.GCP_PROJECT_ID }}/slack-edits-webhook-url - name: Run Alembic migrations on staging database env: @@ -102,6 +103,8 @@ jobs: JIRA_API_TOKEN: "${{ steps.feedback-secrets.outputs.jira_api_token }}" JIRA_DEFAULT_PROJECT: "${{ vars.JIRA_DEFAULT_PROJECT || 'BDMS' }}" SLACK_FEEDBACK_WEBHOOK_URL: "${{ steps.feedback-secrets.outputs.slack_feedback_webhook_url }}" + SLACK_EDITS_WEBHOOK_URL: "${{ steps.feedback-secrets.outputs.slack_edits_webhook_url }}" + OCOTILLO_UI_BASE_URL: "${{ vars.OCOTILLO_UI_BASE_URL || 'https://ocotillo-staging.newmexicowaterdata.org' }}" run: | export MAX_INSTANCES="10" export SERVICE_NAME="ocotillo-api-testing" From 6b9e9c6b803267a965d4b0ae77f1b13487281e94 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 18 Jun 2026 10:50:12 -0400 Subject: [PATCH 15/17] Document how edit notifications will fold into Epic 6 activity logging. --- docs/edit-notifications-and-activity-log.md | 77 +++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 docs/edit-notifications-and-activity-log.md diff --git a/docs/edit-notifications-and-activity-log.md b/docs/edit-notifications-and-activity-log.md new file mode 100644 index 00000000..522c7ff3 --- /dev/null +++ b/docs/edit-notifications-and-activity-log.md @@ -0,0 +1,77 @@ +# Edit notifications and the activity log (Epic 6) + +BDMS-921 adds Slack notifications when Ocotillo data is edited. Epic 6 (activity log) will persist the same events for in-app history. This document describes how the two fit together. + +## Current state (BDMS-921) + +Mutations in the OcotilloAPI service layer call `notify_edit_event(user, event)` from `services/edit_notification_helper.py`. + +- `EditEvent` carries action, resource type/id/label, summary, optional field diffs, and metadata. +- When `SLACK_EDITS_WEBHOOK_URL` is set, the helper posts a Block Kit message to Slack in a background thread. +- When the webhook is unset (local dev), or `user` is not a dict (auth disabled in tests), notification is a no-op. +- Failures are logged and never fail the HTTP request. + +Wired today: + +| Action | Where | +|--------|--------| +| `attachment_uploaded` | `api/asset.py` `upload_and_record_asset` (new uploads only) | +| `project_added` / `project_removed` | `services/group_helper.py` | +| `record_created` / `record_updated` / `record_deleted` | `services/crud_helper.py` | + +## Future state (Epic 6.1) + +Epic 6 introduces an `ActivityLog` table and a service helper, roughly: + +```python +def log_activity( + session, + actor, + action, + resource_type, + resource_id, + *, + resource_label=None, + field_changes=None, + metadata=None, +): + # persist ActivityLog row (not built yet) + notify_edit_event( + actor, + EditEvent( + action=action, + resource_type=resource_type, + resource_id=resource_id, + resource_label=resource_label or f"ID {resource_id}", + summary=_activity_summary(...), + field_changes=field_changes, + metadata=metadata or {}, + ), + ) +``` + +### Migration path + +1. **Keep `EditEvent` as the shared event shape** so Slack payloads and the activity log UI read the same fields (`actor`, `action`, `resource_*`, `field_changes`, `metadata`). +2. **Move call sites from `notify_edit_event` to `log_activity`** as Epic 6.1 lands. `log_activity` writes to PostgreSQL first, then calls `notify_edit_event` as a side effect. +3. **Retire direct `notify_edit_event` calls** in route handlers and one-off helpers once those paths go through `log_activity`. +4. **Map action names** between Slack labels and Epic 6 enums where they differ (e.g. `project_added` → activity log `update` with metadata describing the project change). + +### Field diffs + +`model_patcher` already computes `{field: {before, after}}` for Slack. Epic 6 stores the same JSON on `ActivityLog.field_changes`. No second diff format is needed. + +### Exclusions (unchanged) + +- `POST /feedback` keeps its own Slack webhook. +- Transfer scripts, bulk imports, and non-user mutations should not call `log_activity` or `notify_edit_event`. + +## Environment variables + +| Variable | Purpose | +|----------|---------| +| `SLACK_EDITS_WEBHOOK_URL` | Incoming webhook for edit notifications (Secret Manager in deployed envs) | +| `OCOTILLO_UI_BASE_URL` | UI origin for deep links in Slack messages | +| `ENVIRONMENT` | `staging` or `production`; prefixed in Slack headers | + +See `.env.example` for local defaults. From cf3f5bd991f24ae41d684bc899eaddd214ed6323 Mon Sep 17 00:00:00 2001 From: jeremyzilar <395641+jeremyzilar@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:53:37 +0000 Subject: [PATCH 16/17] Formatting changes --- services/crud_helper.py | 8 ++------ services/edit_notification_helper.py | 4 +--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/services/crud_helper.py b/services/crud_helper.py index dcc0e087..49d1c914 100644 --- a/services/crud_helper.py +++ b/services/crud_helper.py @@ -126,9 +126,7 @@ def model_patcher( resource_type = _resource_type_for_item(model, item) if resource_type: label = _resource_label(item) - field_changes = _compute_field_changes( - before, item, updates.keys() - ) + field_changes = _compute_field_changes(before, item, updates.keys()) summary = f"Updated {resource_type} {label}" notify_edit_event( user, @@ -197,9 +195,7 @@ def _resource_label(item: Any) -> str: def _snapshot_field_values(item: Any, keys: Any) -> dict[str, Any]: - return { - key: _serialize_field_value(getattr(item, key, None)) for key in keys - } + return {key: _serialize_field_value(getattr(item, key, None)) for key in keys} def _compute_field_changes( diff --git a/services/edit_notification_helper.py b/services/edit_notification_helper.py index 3826cd1c..13052972 100644 --- a/services/edit_notification_helper.py +++ b/services/edit_notification_helper.py @@ -134,9 +134,7 @@ def build_slack_payload( heading_action = ACTION_HEADINGS.get(event.action, event.action) header = f"[{env_label}] {heading_action} — {event.resource_label}" - actor_name = ( - user.get("name") or user.get("preferred_username") or "Unknown" - ) + actor_name = user.get("name") or user.get("preferred_username") or "Unknown" actor_email = user.get("email") who = actor_name if not actor_email else f"{actor_name} ({actor_email})" when = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") From 64f4b76441194281c7cc5902704b848a693255a9 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Mon, 22 Jun 2026 12:06:26 -0600 Subject: [PATCH 17/17] Fix lazy admin test for FastAPI _IncludedRouter routes. Newer FastAPI puts routers in app.routes without a path attribute, so walk nested routes when checking for the admin mount. --- tests/test_lazy_admin.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/test_lazy_admin.py b/tests/test_lazy_admin.py index 5b70ed88..ac2f2244 100644 --- a/tests/test_lazy_admin.py +++ b/tests/test_lazy_admin.py @@ -1,14 +1,29 @@ import os +from collections.abc import Iterable from core.factory import create_api_app from fastapi.testclient import TestClient +def _iter_route_paths(routes: Iterable) -> Iterable[str]: + for route in routes: + path = getattr(route, "path", None) + if path: + yield path + nested = getattr(route, "routes", None) + if nested: + yield from _iter_route_paths(nested) + + +def _has_admin_route(routes: Iterable) -> bool: + return any(path.startswith("/admin") for path in _iter_route_paths(routes)) + + def test_admin_is_lazy_loaded_on_first_admin_request(): os.environ["SESSION_SECRET_KEY"] = "test-session-secret-key" app = create_api_app() - assert not any(route.path.startswith("/admin") for route in app.routes) + assert not _has_admin_route(app.routes) assert getattr(app.state, "admin_configured", False) is False with TestClient(app) as client: @@ -16,4 +31,4 @@ def test_admin_is_lazy_loaded_on_first_admin_request(): assert response.status_code in {200, 302, 307} assert app.state.admin_configured is True - assert any(route.path.startswith("/admin") for route in app.routes) + assert _has_admin_route(app.routes)