Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
bb1e6df
Add API routes to add and remove wells from projects.
jeremyzilar Jun 18, 2026
31f1bcc
Add shared helper for Slack edit notifications (BDMS-921).
jeremyzilar Jun 18, 2026
bf05298
Document SLACK_EDITS_WEBHOOK_URL and OCOTILLO_UI_BASE_URL in .env.exa…
jeremyzilar Jun 18, 2026
58d8dd3
Pass edit-notification env vars through the App Engine deploy template.
jeremyzilar Jun 18, 2026
1b1def2
Notify Slack when a well is added to or removed from a project.
jeremyzilar Jun 18, 2026
7ca550f
Pass the authenticated user into group delete and thing-removal route…
jeremyzilar Jun 18, 2026
eaf3e44
Notify Slack after a new asset upload on upload-and-record, not on du…
jeremyzilar Jun 18, 2026
ef49b0f
Emit Slack edit notifications from generic create, update, and delete…
jeremyzilar Jun 18, 2026
c294d17
Add unit tests for edit notification payload building and notify beha…
jeremyzilar Jun 18, 2026
ebfb11c
Add group thing association route tests and Slack notification coverage.
jeremyzilar Jun 18, 2026
5cd7f75
Add upload-and-record Slack notification tests, including duplicate s…
jeremyzilar Jun 18, 2026
23dd102
Wire edit-notification secrets and UI base URL into staging deploy wo…
jeremyzilar Jun 18, 2026
cdb6a01
Wire edit-notification secrets and UI base URL into production deploy…
jeremyzilar Jun 18, 2026
6ad7617
Wire edit-notification secrets and UI base URL into testing deploy wo…
jeremyzilar Jun 18, 2026
6b9e9c6
Document how edit notifications will fold into Epic 6 activity logging.
jeremyzilar Jun 18, 2026
cf3f5bd
Formatting changes
jeremyzilar Jun 18, 2026
953ece4
Merge branch 'staging' into BDMS-921-slack-edit-notifications
jeremyzilar Jun 22, 2026
737bf53
Merge branch 'staging' into BDMS-921-slack-edit-notifications
jeremyzilar Jun 22, 2026
64f4b76
Fix lazy admin test for FastAPI _IncludedRouter routes.
jeremyzilar Jun 22, 2026
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
3 changes: 3 additions & 0 deletions .github/app.template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
3 changes: 3 additions & 0 deletions .github/workflows/CD_production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/CD_staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/CD_testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down
28 changes: 28 additions & 0 deletions api/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down
2 changes: 1 addition & 1 deletion api/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ def remove_thing_from_group_route(
"/{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 =============================================
77 changes: 77 additions & 0 deletions docs/edit-notifications-and-activity-log.md
Original file line number Diff line number Diff line change
@@ -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.
127 changes: 124 additions & 3 deletions services/crud_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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


Expand All @@ -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:
Expand All @@ -91,15 +121,106 @@ 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 =============================================
Loading
Loading