From 71747cf801a69473dfc1955eb995d0c57ec170bf Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 27 Aug 2026 10:41:43 -0500 Subject: [PATCH 1/8] feat(share-with-OSE): update DTO to include owner names, trss, lat & lon --- api/schemas/ose.py | 5 ++++- api/services/ose.py | 49 ++++++++++++++++++++++++++++++++++++--------- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/api/schemas/ose.py b/api/schemas/ose.py index c84a897f..504321b3 100644 --- a/api/schemas/ose.py +++ b/api/schemas/ose.py @@ -21,6 +21,10 @@ class ActivityDTO(BaseModel): activity_start: datetime activity_end: datetime activity_type: str + meter_owner_names: list[str] = Field(default_factory=list) + trss: str | None = None + latitude: float | None = None + longitude: float | None = None well_ra_number: str | None well_ose_tag: str | None description: str @@ -47,4 +51,3 @@ class DisapprovalStatus(BaseModel): notes: str | None = None disapproval_activity: ActivityDTO | None = None new_activities: list[ActivityDTO] | None = None - diff --git a/api/services/ose.py b/api/services/ose.py index a56d1060..84443070 100644 --- a/api/services/ose.py +++ b/api/services/ose.py @@ -58,6 +58,11 @@ def _serialize_activity( activity: MeterActivities, observations: list[MeterObservations] ) -> ose.ActivityDTO: notes_strings = [note.note for note in activity.notes] + meter_owner_names = ( + [activity.meter.meter_owner] + if activity.meter and activity.meter.meter_owner + else [] + ) parts_used_strings = [ f"{part.part.part_number}{f' - {part.part.description}' if part.part.description else ''} x{part.count}" for part in activity.parts_used_links @@ -74,6 +79,11 @@ def _serialize_activity( ) well_ra_number = activity.well.ra_number if activity.well else None well_ose_tag = activity.well.osetag if activity.well else None + location = ( + activity.meter.well.location + if activity.meter and activity.meter.well and activity.meter.well.location + else activity.location + ) meter_activity_photos = [ ose.MeterActivityPhotoDTO( name=photo.file_name, @@ -90,6 +100,10 @@ def _serialize_activity( activity_type=activity.activity_type.name, activity_start=activity.timestamp_start, activity_end=activity.timestamp_end, + meter_owner_names=meter_owner_names, + trss=location.trss if location else None, + latitude=location.latitude if location else None, + longitude=location.longitude if location else None, well_ra_number=well_ra_number, well_ose_tag=well_ose_tag, description=activity.description, @@ -141,9 +155,12 @@ def get_shared_history( .options( joinedload(MeterActivities.activity_type), joinedload(MeterActivities.parts_used_links).joinedload(PartsUsed.part), - joinedload(MeterActivities.meter), + joinedload(MeterActivities.meter) + .joinedload(Meters.well) + .joinedload(Wells.location), joinedload(MeterActivities.work_order), joinedload(MeterActivities.well), + joinedload(MeterActivities.location), joinedload(MeterActivities.notes), joinedload(MeterActivities.services_performed), selectinload(MeterActivities.photos), @@ -152,7 +169,7 @@ def get_shared_history( and_( MeterActivities.timestamp_end >= start_datetime, MeterActivities.timestamp_end <= end_datetime, - MeterActivities.ose_share == True, + MeterActivities.ose_share.is_(True), ) ) ) @@ -172,7 +189,7 @@ def get_shared_history( and_( MeterObservations.timestamp >= start_datetime, MeterObservations.timestamp <= end_datetime, - MeterObservations.ose_share == True, + MeterObservations.ose_share.is_(True), ) ) ) @@ -191,16 +208,22 @@ def get_maintenance_by_request_ids( select(MeterActivities) .options( joinedload(MeterActivities.activity_type), - joinedload(MeterActivities.parts_used_links), - joinedload(MeterActivities.meter).joinedload(Meters.well), + joinedload(MeterActivities.parts_used_links).joinedload(PartsUsed.part), + joinedload(MeterActivities.meter) + .joinedload(Meters.well) + .joinedload(Wells.location), joinedload(MeterActivities.work_order), + joinedload(MeterActivities.well), + joinedload(MeterActivities.location), + joinedload(MeterActivities.notes), + joinedload(MeterActivities.services_performed), selectinload(MeterActivities.photos), ) .join(workOrders) .where( and_( workOrders.ose_request_id.in_(ose_request_ids), - MeterActivities.ose_share == True, + MeterActivities.ose_share.is_(True), ) ) ) @@ -228,7 +251,7 @@ def get_maintenance_by_request_ids( and_( MeterObservations.timestamp >= activities_start_date, MeterObservations.timestamp <= activities_end_date, - MeterObservations.ose_share == True, + MeterObservations.ose_share.is_(True), ) ) ) @@ -312,9 +335,15 @@ def get_disapproval_response(db: Session, ose_request_id: int) -> ose.Disapprova select(MeterActivities) .options( joinedload(MeterActivities.activity_type), - joinedload(MeterActivities.parts_used), - joinedload(MeterActivities.meter).joinedload(Meters.well), + joinedload(MeterActivities.parts_used_links).joinedload(PartsUsed.part), + joinedload(MeterActivities.meter) + .joinedload(Meters.well) + .joinedload(Wells.location), joinedload(MeterActivities.work_order), + joinedload(MeterActivities.well), + joinedload(MeterActivities.location), + joinedload(MeterActivities.notes), + joinedload(MeterActivities.services_performed), selectinload(MeterActivities.photos), ) .where(MeterActivities.work_order_id == work_order.id) @@ -337,7 +366,7 @@ def get_disapproval_response(db: Session, ose_request_id: int) -> ose.Disapprova MeterObservations.timestamp >= activity.timestamp_start, MeterObservations.timestamp <= activity.timestamp_end, MeterObservations.meter_id == activity.meter_id, - MeterObservations.ose_share == True, + MeterObservations.ose_share.is_(True), ) ) ) From 7f822f204648972b62c66fcf1ef726e9baec3970 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 28 Aug 2026 09:21:36 -0500 Subject: [PATCH 2/8] feat(owner_change_requests): init owner change request --- api/config.py | 2 + api/models/__init__.py | 4 + api/models/meter.py | 60 ++- api/routes/admin.py | 435 +++++++++++++++++- api/routes/meters.py | 66 ++- api/schemas/admin.py | 42 ++ api/schemas/meter.py | 13 + docker-compose.development.yml | 2 + docker-compose.production.yml | 2 + frontend/src/interfaces/Meter.ts | 30 +- frontend/src/interfaces/MeterContact.ts | 9 + frontend/src/interfaces/MeterDetails.ts | 2 + .../src/interfaces/MeterOwnerChangeRequest.ts | 22 + frontend/src/interfaces/index.ts | 2 + frontend/src/routes/notifications.tsx | 1 + frontend/src/service/notifications.ts | 134 +++++- .../src/views/Meters/MeterDetailsFields.tsx | 161 +++++-- frontend/src/views/Notifications.tsx | 232 +++++++++- ...ontacts_and_owner_change_requests.down.sql | 2 + ..._contacts_and_owner_change_requests.up.sql | 70 +++ 20 files changed, 1222 insertions(+), 69 deletions(-) create mode 100644 frontend/src/interfaces/MeterContact.ts create mode 100644 frontend/src/interfaces/MeterOwnerChangeRequest.ts create mode 100644 migrations/20260828000000_add_meter_contacts_and_owner_change_requests.down.sql create mode 100644 migrations/20260828000000_add_meter_contacts_and_owner_change_requests.up.sql diff --git a/api/config.py b/api/config.py index a6309493..ac09460a 100644 --- a/api/config.py +++ b/api/config.py @@ -38,6 +38,8 @@ class Settings: "production" if os.getenv("APPDB_ENV") == ".env_production" else "development", ) ALLOW_IMPERSONATION: bool = APP_ENV in {"development", "pre-production"} + OSE_BULK_EXPORT_URL: Optional[str] = os.getenv("OSE_BULK_EXPORT_URL") + OSE_BULK_EXPORT_LIMIT: int = int(os.getenv("OSE_BULK_EXPORT_LIMIT", "100")) DATABASE_URL = f"postgresql+psycopg://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}" diff --git a/api/models/__init__.py b/api/models/__init__.py index 8a4cd1dd..90ea87d5 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -2,8 +2,10 @@ from api.models.location import LandOwners, Locations, LocationTypeLU from api.models.meter import ( ActivityTypeLU, + MeterContacts, MeterActivities, MeterActivityPhotos, + MeterOwnerChangeRequests, MeterObservations, MeterStatusLU, MeterTypeLU, @@ -40,7 +42,9 @@ "LocationTypeLU", "MeterActivities", "MeterActivityPhotos", + "MeterContacts", "MeterObservations", + "MeterOwnerChangeRequests", "MeterStatusLU", "MeterTypeLU", "Meters", diff --git a/api/models/meter.py b/api/models/meter.py index c3f49017..642ff791 100644 --- a/api/models/meter.py +++ b/api/models/meter.py @@ -1,4 +1,6 @@ -from typing import List, Optional +from __future__ import annotations + +from typing import TYPE_CHECKING, List, Optional from sqlalchemy import ( Boolean, @@ -12,10 +14,18 @@ Table, func, ) +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship from api.models.base import Base +if TYPE_CHECKING: + from api.models.location import Locations + from api.models.part import PartsUsed + from api.models.user import Users + from api.models.well import Wells + from api.models.work_order import workOrders + class ServiceTypeLU(Base): __tablename__ = "ServiceTypeLU" @@ -78,6 +88,54 @@ class Meters(Base): status: Mapped["MeterStatusLU"] = relationship() well: Mapped["Wells"] = relationship("Wells", back_populates="meters") location: Mapped["Locations"] = relationship() + contacts: Mapped[List["MeterContacts"]] = relationship( + "MeterContacts", + back_populates="meter", + cascade="all, delete-orphan", + order_by="MeterContacts.id", + ) + + +class MeterContacts(Base): + __tablename__ = "meter_contacts" + + meter_id: Mapped[int] = mapped_column( + Integer, ForeignKey("Meters.id", ondelete="CASCADE", onupdate="CASCADE"), index=True + ) + name: Mapped[Optional[str]] = mapped_column(String) + phone: Mapped[Optional[str]] = mapped_column(String) + cell: Mapped[Optional[str]] = mapped_column(String) + email: Mapped[Optional[str]] = mapped_column(String) + address: Mapped[Optional[str]] = mapped_column(String) + + meter: Mapped["Meters"] = relationship("Meters", back_populates="contacts") + + +class MeterOwnerChangeRequests(Base): + __tablename__ = "meter_owner_change_requests" + + meter_id: Mapped[int] = mapped_column( + Integer, ForeignKey("Meters.id", ondelete="CASCADE", onupdate="CASCADE"), index=True + ) + serial_number: Mapped[str] = mapped_column(String, nullable=False) + ose_meter_id: Mapped[Optional[int]] = mapped_column(Integer) + old_water_users: Mapped[Optional[str]] = mapped_column(String) + new_water_users: Mapped[Optional[str]] = mapped_column(String) + old_contacts: Mapped[list[dict]] = mapped_column(JSONB, nullable=False, default=list) + new_contacts: Mapped[list[dict]] = mapped_column(JSONB, nullable=False, default=list) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending") + created_by: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("Users.id", ondelete="SET NULL", onupdate="CASCADE"), index=True + ) + resolved_by: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("Users.id", ondelete="SET NULL", onupdate="CASCADE"), index=True + ) + created_at: Mapped[DateTime] = mapped_column( + DateTime, nullable=False, server_default=func.now(), index=True + ) + resolved_at: Mapped[Optional[DateTime]] = mapped_column(DateTime) + + meter: Mapped["Meters"] = relationship() class MeterTypeLU(Base): diff --git a/api/routes/admin.py b/api/routes/admin.py index 8e46b475..cfae7fce 100644 --- a/api/routes/admin.py +++ b/api/routes/admin.py @@ -1,14 +1,26 @@ from datetime import datetime, timezone, timedelta +import json import secrets import string +from urllib.error import HTTPError, URLError +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit +from urllib.request import Request as URLRequest, urlopen -from fastapi import Depends, APIRouter, HTTPException, Request +from fastapi import Depends, APIRouter, HTTPException, Query, Request from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session, joinedload, undefer from sqlalchemy import select from typing import List -from api.models.user import ServiceAccountApiKeys, Users, UserRoles, SecurityScopes +from api.models.meter import MeterContacts, MeterOwnerChangeRequests, Meters +from api.models.user import ( + NotificationTypeLU, + Notifications, + ServiceAccountApiKeys, + Users, + UserRoles, + SecurityScopes, +) from api.schemas import security from api.schemas import admin @@ -47,6 +59,201 @@ BACKUP_FILENAME_DATE_RE = re.compile(r"^backup-(\d{4}-\d{2}-\d{2})-\d+\.dump$") +def _build_ose_bulk_url(offset: int, limit: int) -> str: + if not settings.OSE_BULK_EXPORT_URL: + raise HTTPException(status_code=500, detail="OSE_BULK_EXPORT_URL is not set") + + parsed_url = urlsplit(settings.OSE_BULK_EXPORT_URL) + query_params = dict(parse_qsl(parsed_url.query, keep_blank_values=True)) + query_params["offset"] = str(offset) + query_params["limit"] = str(limit) + + return urlunsplit( + ( + parsed_url.scheme, + parsed_url.netloc, + parsed_url.path, + urlencode(query_params), + parsed_url.fragment, + ) + ) + + +def _fetch_ose_bulk_page(offset: int, limit: int) -> dict: + request = URLRequest( + _build_ose_bulk_url(offset, limit), + headers={"Accept": "application/json"}, + method="GET", + ) + + try: + with urlopen(request, timeout=60) as response: + return json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + raise HTTPException( + status_code=502, + detail=f"OSE bulk export returned HTTP {exc.code}", + ) + except (URLError, TimeoutError, json.JSONDecodeError) as exc: + raise HTTPException( + status_code=502, + detail=f"Unable to fetch OSE bulk export: {exc}", + ) + + +def _owner_names_from_ose_meter(ose_meter: dict) -> str | None: + names = [ + owner.get("name") + for owner in ose_meter.get("owners") or [] + if owner.get("name") is not None + ] + return ", ".join(names) if names else None + + +def _contacts_from_ose_meter(ose_meter: dict) -> list[dict]: + contacts = [] + for owner in ose_meter.get("owners") or []: + for contact in owner.get("contacts") or []: + contacts.append( + { + "name": contact.get("name"), + "phone": contact.get("phone"), + "cell": contact.get("cell"), + "email": contact.get("email"), + "address": contact.get("address"), + } + ) + return contacts + + +def _contacts_from_meter(meter: Meters) -> list[dict]: + if meter.contacts: + return [ + { + "name": contact.name, + "phone": contact.phone, + "cell": contact.cell, + "email": contact.email, + "address": contact.address, + } + for contact in meter.contacts + ] + + if meter.contact_name or meter.contact_phone: + return [ + { + "name": meter.contact_name, + "phone": meter.contact_phone, + "cell": None, + "email": None, + "address": None, + } + ] + + return [] + + +def _set_meter_contacts(meter: Meters, contacts: list[dict]) -> None: + meter.contacts.clear() + for contact in contacts: + if not any(contact.values()): + continue + meter.contacts.append( + MeterContacts( + name=contact.get("name"), + phone=contact.get("phone"), + cell=contact.get("cell"), + email=contact.get("email"), + address=contact.get("address"), + ) + ) + + first_contact = next((contact for contact in contacts if any(contact.values())), None) + meter.contact_name = first_contact.get("name") if first_contact else None + meter.contact_phone = first_contact.get("phone") if first_contact else None + + +def _admin_user_ids(db: Session) -> list[int]: + return db.scalars( + select(Users.id) + .join(UserRoles, Users.user_role_id == UserRoles.id) + .join(UserRoles.security_scopes) + .where(SecurityScopes.scope_string == "admin", Users.disabled.is_(False)) + ).all() + + +def _owner_change_type_id(db: Session) -> int: + notification_type_id = db.scalar( + select(NotificationTypeLU.id).where(NotificationTypeLU.name == "owner_change") + ) + if not notification_type_id: + raise HTTPException( + status_code=500, + detail="Notification type owner_change does not exist", + ) + return notification_type_id + + +def _create_owner_change_notifications( + db: Session, + change_request: MeterOwnerChangeRequests, + created_by: int | None, +) -> int: + notification_type_id = _owner_change_type_id(db) + notifications = [ + Notifications( + user_id=user_id, + notification_type_id=notification_type_id, + created_by=created_by, + title=f"Owner Change: Meter {change_request.serial_number}", + message=( + "OSE owner or contact information differs from Meter Manager. " + "Review and accept the selected changes." + ), + link=f"/notifications?owner_change_request_id={change_request.id}", + ) + for user_id in _admin_user_ids(db) + ] + db.add_all(notifications) + return len(notifications) + + +def _apply_owner_change_request( + db: Session, + change_request: MeterOwnerChangeRequests, + current_admin: Users, + apply_water_users: bool, + apply_contacts: bool, +) -> MeterOwnerChangeRequests: + if change_request.status not in {"pending", "partially_accepted"}: + raise HTTPException(status_code=400, detail="Owner change request is resolved") + + if not apply_water_users and not apply_contacts: + raise HTTPException(status_code=400, detail="No changes selected") + + meter = db.scalars( + select(Meters) + .options(joinedload(Meters.contacts)) + .where(Meters.id == change_request.meter_id) + ).unique().first() + if not meter: + raise HTTPException(status_code=404, detail="Meter not found") + + if apply_water_users: + meter.water_users = change_request.new_water_users + if apply_contacts: + _set_meter_contacts(meter, change_request.new_contacts) + + change_request.status = ( + "accepted" if apply_water_users and apply_contacts else "partially_accepted" + ) + change_request.resolved_by = current_admin.id + change_request.resolved_at = datetime.now(timezone.utc) + db.add(meter) + db.add(change_request) + return change_request + + def _backup_filename_date(file_name: str) -> dt.date | None: match = BACKUP_FILENAME_DATE_RE.match(file_name) if not match: @@ -113,6 +320,230 @@ def _validate_new_password(password: str, user: Users) -> None: apply_password_evaluation(user, evaluation) +@admin_router.post( + "/admin/ose-owner-sync", + response_model=admin.OSEOwnerSyncResult, + dependencies=[Depends(ScopedUser.Admin)], + tags=["Admin"], +) +def sync_ose_owner_changes( + db: Session = Depends(get_db), + current_admin: Users = Depends(ScopedUser.Admin), +): + limit = settings.OSE_BULK_EXPORT_LIMIT + offset = 0 + fetched_count = 0 + matched_count = 0 + changed_count = 0 + created_request_count = 0 + notification_count = 0 + unmatched_count = 0 + skipped_pending_count = 0 + + while True: + page = _fetch_ose_bulk_page(offset=offset, limit=limit) + ose_meters = page.get("data") or [] + meta = page.get("meta") or {} + + if not ose_meters: + break + + fetched_count += len(ose_meters) + serial_numbers = [ + ose_meter.get("serial_number") + for ose_meter in ose_meters + if ose_meter.get("serial_number") is not None + ] + local_meters = { + local_meter.serial_number: local_meter + for local_meter in db.scalars( + select(Meters) + .options(joinedload(Meters.contacts)) + .where(Meters.serial_number.in_(serial_numbers)) + ).unique() + } + + for ose_meter in ose_meters: + serial_number = ose_meter.get("serial_number") + local_meter = local_meters.get(serial_number) + if not local_meter: + unmatched_count += 1 + continue + + matched_count += 1 + new_water_users = _owner_names_from_ose_meter(ose_meter) + new_contacts = _contacts_from_ose_meter(ose_meter) + old_contacts = _contacts_from_meter(local_meter) + + if ( + local_meter.water_users == new_water_users + and old_contacts == new_contacts + ): + continue + + changed_count += 1 + pending_exists = db.scalar( + select(MeterOwnerChangeRequests.id).where( + MeterOwnerChangeRequests.meter_id == local_meter.id, + MeterOwnerChangeRequests.status.in_( + ["pending", "partially_accepted"] + ), + ) + ) + if pending_exists: + skipped_pending_count += 1 + continue + + change_request = MeterOwnerChangeRequests( + meter_id=local_meter.id, + serial_number=local_meter.serial_number, + ose_meter_id=ose_meter.get("meter_id"), + old_water_users=local_meter.water_users, + new_water_users=new_water_users, + old_contacts=old_contacts, + new_contacts=new_contacts, + created_by=current_admin.id, + ) + db.add(change_request) + db.flush() + notification_count += _create_owner_change_notifications( + db, + change_request, + current_admin.id, + ) + created_request_count += 1 + + offset += int(meta.get("limit") or limit) + total_count = meta.get("count") + if len(ose_meters) < limit or ( + total_count is not None and offset >= int(total_count) + ): + break + + db.commit() + + return { + "fetched_count": fetched_count, + "matched_count": matched_count, + "changed_count": changed_count, + "created_request_count": created_request_count, + "notification_count": notification_count, + "unmatched_count": unmatched_count, + "skipped_pending_count": skipped_pending_count, + } + + +@admin_router.get( + "/admin/ose-owner-change-requests", + response_model=list[admin.MeterOwnerChangeRequest], + dependencies=[Depends(ScopedUser.Admin)], + tags=["Admin"], +) +def get_ose_owner_change_requests( + status: list[str] | None = Query(["pending", "partially_accepted"]), + db: Session = Depends(get_db), +): + query = select(MeterOwnerChangeRequests).order_by( + MeterOwnerChangeRequests.created_at.desc(), + MeterOwnerChangeRequests.id.desc(), + ) + if status: + query = query.where(MeterOwnerChangeRequests.status.in_(status)) + return db.scalars(query).all() + + +@admin_router.post( + "/admin/ose-owner-change-requests/{request_id}/accept", + response_model=admin.MeterOwnerChangeRequest, + dependencies=[Depends(ScopedUser.Admin)], + tags=["Admin"], +) +def accept_ose_owner_change_request( + request_id: int, + payload: admin.MeterOwnerChangeAcceptRequest, + db: Session = Depends(get_db), + current_admin: Users = Depends(ScopedUser.Admin), +): + change_request = db.scalars( + select(MeterOwnerChangeRequests).where( + MeterOwnerChangeRequests.id == request_id + ) + ).first() + if not change_request: + raise HTTPException(status_code=404, detail="Owner change request not found") + + _apply_owner_change_request( + db, + change_request, + current_admin, + payload.apply_water_users, + payload.apply_contacts, + ) + db.commit() + db.refresh(change_request) + return change_request + + +@admin_router.post( + "/admin/ose-owner-change-requests/{request_id}/reject", + response_model=admin.MeterOwnerChangeRequest, + dependencies=[Depends(ScopedUser.Admin)], + tags=["Admin"], +) +def reject_ose_owner_change_request( + request_id: int, + db: Session = Depends(get_db), + current_admin: Users = Depends(ScopedUser.Admin), +): + change_request = db.scalars( + select(MeterOwnerChangeRequests).where( + MeterOwnerChangeRequests.id == request_id + ) + ).first() + if not change_request: + raise HTTPException(status_code=404, detail="Owner change request not found") + + if change_request.status not in {"pending", "partially_accepted"}: + raise HTTPException(status_code=400, detail="Owner change request is resolved") + + change_request.status = "rejected" + change_request.resolved_by = current_admin.id + change_request.resolved_at = datetime.now(timezone.utc) + db.add(change_request) + db.commit() + db.refresh(change_request) + return change_request + + +@admin_router.post( + "/admin/ose-owner-change-requests/accept-all", + response_model=admin.MeterOwnerChangeBulkAcceptResult, + dependencies=[Depends(ScopedUser.Admin)], + tags=["Admin"], +) +def accept_all_ose_owner_change_requests( + db: Session = Depends(get_db), + current_admin: Users = Depends(ScopedUser.Admin), +): + pending_requests = db.scalars( + select(MeterOwnerChangeRequests).where( + MeterOwnerChangeRequests.status.in_(["pending", "partially_accepted"]) + ) + ).all() + + for change_request in pending_requests: + _apply_owner_change_request( + db, + change_request, + current_admin, + apply_water_users=True, + apply_contacts=True, + ) + + db.commit() + return {"accepted_count": len(pending_requests)} + + def _serialize_service_account( service_account: Users, api_key: str | None = None, diff --git a/api/routes/meters.py b/api/routes/meters.py index 9a33a8aa..e5382fd9 100644 --- a/api/routes/meters.py +++ b/api/routes/meters.py @@ -12,6 +12,7 @@ from api.models.location import LandOwners, Locations from api.models.meter import ( ActivityTypeLU, + MeterContacts, Meters, MeterStatusLU, MeterTypeLU, @@ -28,6 +29,43 @@ public_meter_router = APIRouter() +def _contact_has_value(contact: meter.MeterContact) -> bool: + return any( + [ + contact.name, + contact.phone, + contact.cell, + contact.email, + contact.address, + ] + ) + + +def _replace_meter_contacts( + db: Session, meter_db: Meters, contacts: list[meter.MeterContact] +) -> None: + meter_db.contacts.clear() + for contact in contacts: + if not _contact_has_value(contact): + continue + meter_db.contacts.append( + MeterContacts( + name=contact.name, + phone=contact.phone, + cell=contact.cell, + email=contact.email, + address=contact.address, + ) + ) + + first_contact = next( + (contact for contact in contacts if _contact_has_value(contact)), None + ) + meter_db.contact_name = first_contact.name if first_contact else None + meter_db.contact_phone = first_contact.phone if first_contact else None + db.add(meter_db) + + # Get paginated, sorted list of meters, filtered by a search string if applicable @authenticated_meter_router.get( "/meters", @@ -292,6 +330,16 @@ def create_meter(new_meter: meter.SubmitNewMeter, db: Session = Depends(get_db)) new_meter_model.well_id = new_meter.well.id new_meter_model.location_id = new_meter.well.location_id + contacts = new_meter.contacts + if not contacts and (new_meter.contact_name or new_meter.contact_phone): + contacts = [ + meter.MeterContact( + name=new_meter.contact_name, + phone=new_meter.contact_phone, + ) + ] + _replace_meter_contacts(db, new_meter_model, contacts) + # Try adding the meter, if it fails due to integrety error... try: db.add(new_meter_model) @@ -495,6 +543,7 @@ def get_meter( joinedload(Meters.meter_type), joinedload(Meters.well).joinedload(Wells.location), joinedload(Meters.status), + joinedload(Meters.contacts), joinedload(Meters.meter_register).joinedload(meterRegisters.dial_units), joinedload(Meters.meter_register).joinedload(meterRegisters.totalizer_units), ) @@ -505,7 +554,7 @@ def get_meter( else: query = query.filter(Meters.serial_number == serial_number) - return db.scalars(query).first() + return db.scalars(query).unique().first() @authenticated_meter_router.get( @@ -613,8 +662,6 @@ def patch_meter(updated_meter: meter.SubmitMeterUpdate, db: Session = Depends(ge meter_db = _get(db, Meters, updated_meter.id) meter_db.serial_number = updated_meter.serial_number - meter_db.contact_name = updated_meter.contact_name - meter_db.contact_phone = updated_meter.contact_phone meter_db.notes = updated_meter.notes meter_db.price = updated_meter.price meter_db.meter_type_id = updated_meter.meter_type.id @@ -639,6 +686,16 @@ def patch_meter(updated_meter: meter.SubmitMeterUpdate, db: Session = Depends(ge if updated_meter.status: meter_db.status_id = updated_meter.status.id + contacts = updated_meter.contacts + if not contacts and (updated_meter.contact_name or updated_meter.contact_phone): + contacts = [ + meter.MeterContact( + name=updated_meter.contact_name, + phone=updated_meter.contact_phone, + ) + ] + _replace_meter_contacts(db, meter_db, contacts) + try: db.add(meter_db) db.commit() @@ -651,9 +708,10 @@ def patch_meter(updated_meter: meter.SubmitMeterUpdate, db: Session = Depends(ge joinedload(Meters.meter_type), joinedload(Meters.well).joinedload(Wells.location), joinedload(Meters.status), + joinedload(Meters.contacts), ) .filter(Meters.id == updated_meter.id) - ).first() + ).unique().first() @authenticated_meter_router.get( diff --git a/api/schemas/admin.py b/api/schemas/admin.py index 6ba18c66..8cffc21c 100644 --- a/api/schemas/admin.py +++ b/api/schemas/admin.py @@ -11,3 +11,45 @@ class BackupFile(ORMBase): format: str gs_uri: str created_utc: Optional[datetime.datetime] = None + + +class MeterContactSnapshot(BaseModel): + name: str | None = None + phone: str | None = None + cell: str | None = None + email: str | None = None + address: str | None = None + + +class MeterOwnerChangeRequest(ORMBase): + meter_id: int + serial_number: str + ose_meter_id: int | None = None + old_water_users: str | None = None + new_water_users: str | None = None + old_contacts: list[MeterContactSnapshot] = [] + new_contacts: list[MeterContactSnapshot] = [] + status: str + created_by: int | None = None + resolved_by: int | None = None + created_at: datetime.datetime + resolved_at: datetime.datetime | None = None + + +class OSEOwnerSyncResult(BaseModel): + fetched_count: int + matched_count: int + changed_count: int + created_request_count: int + notification_count: int + unmatched_count: int + skipped_pending_count: int + + +class MeterOwnerChangeAcceptRequest(BaseModel): + apply_water_users: bool = True + apply_contacts: bool = True + + +class MeterOwnerChangeBulkAcceptResult(BaseModel): + accepted_count: int diff --git a/api/schemas/meter.py b/api/schemas/meter.py index a731de8d..025b67f4 100644 --- a/api/schemas/meter.py +++ b/api/schemas/meter.py @@ -88,6 +88,15 @@ class MeterStatusLU(ORMBase): description: str | None = None +class MeterContact(ORMBase): + meter_id: int | None = None + name: str | None = None + phone: str | None = None + cell: str | None = None + email: str | None = None + address: str | None = None + + class NoteTypeLU(ORMBase): note: str | None = None details: str | None = None @@ -100,6 +109,7 @@ class SubmitNewMeter(ORMBase): contact_name: str | None = None contact_phone: str | None = None + contacts: list["MeterContact"] = [] notes: str | None = None price: Decimal | None = None well: Well | None = None @@ -110,6 +120,7 @@ class SubmitMeterUpdate(ORMBase): serial_number: str contact_name: str | None = None contact_phone: str | None = None + contacts: list["MeterContact"] = [] notes: str | None = None price: Decimal | None = None meter_type: MeterTypeLU @@ -124,6 +135,7 @@ class Meter(ORMBase): serial_number: str contact_name: str | None = None contact_phone: str | None = None + contacts: list["MeterContact"] = [] notes: str | None = None meter_type_id: int @@ -136,6 +148,7 @@ class Meter(ORMBase): well: Well | None = None location: Location | None = None + class PublicMeter(BaseModel): ''' Used for displaying meter information to the public, specifically the OSE diff --git a/docker-compose.development.yml b/docker-compose.development.yml index 4c1983fe..c9b08cae 100644 --- a/docker-compose.development.yml +++ b/docker-compose.development.yml @@ -47,6 +47,8 @@ services: environment: - APP_ENV=pre-production - API_BASE_URL=https://metermanager-dev.pvacd.com/api/v1 + - OSE_BULK_EXPORT_URL=https://watermanagement.oseisc.org/watermanagement/pvacd/meters/bulk-export + - OSE_BULK_EXPORT_LIMIT=100 - GCP_BUCKET_NAME=meter-manager-bucket - GCP_WOODPECKER_BUCKET_NAME=meter-manager-woodpecker-bucket - GCP_BACKUP_PREFIX=pre-prod-db-backups diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 1f4bf8d9..679da432 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -47,6 +47,8 @@ services: environment: - APP_ENV=production - API_BASE_URL=https://metermanager.pvacd.com/api/v1 + - OSE_BULK_EXPORT_URL=https://watermanagement.oseisc.org/watermanagement/pvacd/meters/bulk-export + - OSE_BULK_EXPORT_LIMIT=100 - GCP_BUCKET_NAME=meter-manager-bucket - GCP_WOODPECKER_BUCKET_NAME=meter-manager-woodpecker-bucket - GCP_BACKUP_PREFIX=prod-db-backups diff --git a/frontend/src/interfaces/Meter.ts b/frontend/src/interfaces/Meter.ts index 10d9aa9f..7bd0db02 100644 --- a/frontend/src/interfaces/Meter.ts +++ b/frontend/src/interfaces/Meter.ts @@ -3,23 +3,25 @@ import type { MeterRegister } from "./MeterRegister"; import type { MeterStatus } from "./MeterStatus"; import type { MeterType } from "./MeterType"; import type { Well } from "./Well"; +import type { MeterContact } from "./MeterContact"; export interface Meter { - id: number; - serial_number: string; - contact_name?: string; - contact_phone?: string; - notes?: string; - price?: number; + id?: number | null; + serial_number?: string | null; + contact_name?: string | null; + contact_phone?: string | null; + contacts?: MeterContact[]; + notes?: string | null; + price?: number | null; - meter_type_id: number; - status_id?: number; - well_id: number; + meter_type_id?: number | null; + status_id?: number | null; + well_id?: number | null; location_id?: number; - meter_register?: MeterRegister; - meter_type?: MeterType; - status?: MeterStatus; - well?: Well; - location?: Location; + meter_register?: MeterRegister | null; + meter_type?: MeterType | null; + status?: MeterStatus | null; + well?: Well | null; + location?: Location | null; } diff --git a/frontend/src/interfaces/MeterContact.ts b/frontend/src/interfaces/MeterContact.ts new file mode 100644 index 00000000..a391cc7d --- /dev/null +++ b/frontend/src/interfaces/MeterContact.ts @@ -0,0 +1,9 @@ +export interface MeterContact { + id?: number | null; + meter_id?: number | null; + name?: string | null; + phone?: string | null; + cell?: string | null; + email?: string | null; + address?: string | null; +} diff --git a/frontend/src/interfaces/MeterDetails.ts b/frontend/src/interfaces/MeterDetails.ts index 49266399..f1943645 100644 --- a/frontend/src/interfaces/MeterDetails.ts +++ b/frontend/src/interfaces/MeterDetails.ts @@ -3,12 +3,14 @@ import type { MeterRegister } from "./MeterRegister"; import type { MeterStatus } from "./MeterStatus"; import type { MeterType } from "./MeterType"; import type { Well } from "./Well"; +import type { MeterContact } from "./MeterContact"; export interface MeterDetails { id?: number | null; serial_number?: string | null; contact_name?: string | null; contact_phone?: string | null; + contacts?: MeterContact[]; water_users?: string | null; meter_owner?: string | null; ra_number?: string | null; diff --git a/frontend/src/interfaces/MeterOwnerChangeRequest.ts b/frontend/src/interfaces/MeterOwnerChangeRequest.ts new file mode 100644 index 00000000..537eda78 --- /dev/null +++ b/frontend/src/interfaces/MeterOwnerChangeRequest.ts @@ -0,0 +1,22 @@ +import type { MeterContact } from "./MeterContact"; + +export interface MeterOwnerChangeRequest { + id: number; + meter_id: number; + serial_number: string; + ose_meter_id?: number | null; + old_water_users?: string | null; + new_water_users?: string | null; + old_contacts: MeterContact[]; + new_contacts: MeterContact[]; + status: string; + created_by?: number | null; + resolved_by?: number | null; + created_at: string; + resolved_at?: string | null; +} + +export interface MeterOwnerChangeAcceptPayload { + apply_water_users: boolean; + apply_contacts: boolean; +} diff --git a/frontend/src/interfaces/index.ts b/frontend/src/interfaces/index.ts index 9a33d070..9da6c88c 100644 --- a/frontend/src/interfaces/index.ts +++ b/frontend/src/interfaces/index.ts @@ -16,6 +16,7 @@ export * from "./Location"; export * from "./Measurement"; export * from "./Meter"; export * from "./MeterActivity"; +export * from "./MeterContact"; export * from "./MeterDetails"; export * from "./MeterDetailsQueryParams"; export * from "./MeterHistoryDTO"; @@ -24,6 +25,7 @@ export * from "./MeterListQuery"; export * from "./MeterListQueryParams"; export * from "./MeterListSortBy"; export * from "./MeterMapDTO"; +export * from "./MeterOwnerChangeRequest"; export * from "./MeterPartParams"; export * from "./MeterRegister"; export * from "./MeterStatus"; diff --git a/frontend/src/routes/notifications.tsx b/frontend/src/routes/notifications.tsx index 04d63af5..8888f406 100644 --- a/frontend/src/routes/notifications.tsx +++ b/frontend/src/routes/notifications.tsx @@ -19,6 +19,7 @@ const searchSchema = z.object({ created_to: isoDateParam .catch(dayjs().endOf("month").format("YYYY-MM-DD")) .default(dayjs().endOf("month").format("YYYY-MM-DD")), + owner_change_request_id: z.coerce.number().int().positive().optional(), page: pageParam(0, 0), pageSize: pageParam(25, 10), }); diff --git a/frontend/src/service/notifications.ts b/frontend/src/service/notifications.ts index e243c3ea..8e9173f9 100644 --- a/frontend/src/service/notifications.ts +++ b/frontend/src/service/notifications.ts @@ -7,9 +7,13 @@ import { NotificationCreateResult, NotificationQueryParams, NotificationType, + MeterOwnerChangeAcceptPayload, + MeterOwnerChangeRequest, Page, } from "@/interfaces"; +type MutationSuccessHandler = (result: T) => void; + export function useGetNotifications( params: NotificationQueryParams | undefined, options?: UseQueryOptions, Error>, @@ -52,7 +56,9 @@ export function useGetUnreadNotificationCount( ); } -export function useCreateNotifications(onSuccess: Function) { +export function useCreateNotifications( + onSuccess: MutationSuccessHandler, +) { const { enqueueSnackbar } = useSnackbar(); const queryClient = useQueryClient(); const apiClient = useApiClient(); @@ -88,7 +94,9 @@ export function useCreateNotifications(onSuccess: Function) { }); } -export function useUpdateNotificationReadStatus(onSuccess?: Function) { +export function useUpdateNotificationReadStatus( + onSuccess?: MutationSuccessHandler, +) { const { enqueueSnackbar } = useSnackbar(); const queryClient = useQueryClient(); const apiClient = useApiClient(); @@ -116,3 +124,125 @@ export function useUpdateNotificationReadStatus(onSuccess?: Function) { retry: 0, }); } + +export function useGetOwnerChangeRequests( + options?: UseQueryOptions, +) { + const apiClient = useApiClient(); + const route = "admin/ose-owner-change-requests"; + + return useQuery( + [route], + () => apiClient.get(route), + options, + ); +} + +export function useAcceptOwnerChangeRequest( + onSuccess?: MutationSuccessHandler, +) { + const { enqueueSnackbar } = useSnackbar(); + const queryClient = useQueryClient(); + const apiClient = useApiClient(); + + return useMutation({ + mutationFn: async ({ + id, + payload, + }: { + id: number; + payload: MeterOwnerChangeAcceptPayload; + }) => { + const response = await apiClient.post( + `admin/ose-owner-change-requests/${id}/accept`, + payload, + ); + + if (!response.ok) { + const errorMessage = + (await response.json().catch(() => null))?.detail ?? + `Error ${response.status}`; + enqueueSnackbar(errorMessage, { variant: "error" }); + throw Error(errorMessage); + } + + return response.json(); + }, + onSuccess: (result) => { + queryClient.invalidateQueries("admin/ose-owner-change-requests"); + queryClient.invalidateQueries("meter"); + queryClient.invalidateQueries("meters"); + enqueueSnackbar("Owner change applied.", { variant: "success" }); + onSuccess?.(result); + }, + retry: 0, + }); +} + +export function useRejectOwnerChangeRequest( + onSuccess?: MutationSuccessHandler, +) { + const { enqueueSnackbar } = useSnackbar(); + const queryClient = useQueryClient(); + const apiClient = useApiClient(); + + return useMutation({ + mutationFn: async (id: number) => { + const response = await apiClient.post( + `admin/ose-owner-change-requests/${id}/reject`, + {}, + ); + + if (!response.ok) { + const errorMessage = + (await response.json().catch(() => null))?.detail ?? + `Error ${response.status}`; + enqueueSnackbar(errorMessage, { variant: "error" }); + throw Error(errorMessage); + } + + return response.json(); + }, + onSuccess: (result) => { + queryClient.invalidateQueries("admin/ose-owner-change-requests"); + enqueueSnackbar("Owner change rejected.", { variant: "success" }); + onSuccess?.(result); + }, + retry: 0, + }); +} + +export function useAcceptAllOwnerChangeRequests( + onSuccess?: MutationSuccessHandler<{ accepted_count: number }>, +) { + const { enqueueSnackbar } = useSnackbar(); + const queryClient = useQueryClient(); + const apiClient = useApiClient(); + const route = "admin/ose-owner-change-requests/accept-all"; + + return useMutation({ + mutationFn: async () => { + const response = await apiClient.post(route, {}); + + if (!response.ok) { + const errorMessage = + (await response.json().catch(() => null))?.detail ?? + `Error ${response.status}`; + enqueueSnackbar(errorMessage, { variant: "error" }); + throw Error(errorMessage); + } + + return response.json(); + }, + onSuccess: (result) => { + queryClient.invalidateQueries("admin/ose-owner-change-requests"); + queryClient.invalidateQueries("meter"); + queryClient.invalidateQueries("meters"); + enqueueSnackbar(`Applied ${result.accepted_count} owner changes.`, { + variant: "success", + }); + onSuccess?.(result); + }, + retry: 0, + }); +} diff --git a/frontend/src/views/Meters/MeterDetailsFields.tsx b/frontend/src/views/Meters/MeterDetailsFields.tsx index 0f578c5c..8c30d383 100644 --- a/frontend/src/views/Meters/MeterDetailsFields.tsx +++ b/frontend/src/views/Meters/MeterDetailsFields.tsx @@ -1,16 +1,24 @@ import { useEffect, useState } from "react"; -import { useForm, SubmitHandler } from "react-hook-form"; +import { + useFieldArray, + useForm, + Resolver, + SubmitHandler, + SubmitErrorHandler, +} from "react-hook-form"; import { enqueueSnackbar } from "notistack"; import { useAuthUser } from "react-auth-kit"; import { useNavigate } from "@tanstack/react-router"; -import { Add, Grading, Save, SaveAs } from "@mui/icons-material"; +import { Add, Delete, Grading, Save, SaveAs } from "@mui/icons-material"; import { Button, Grid, Card, CardContent, + IconButton, InputAdornment, Skeleton, + Typography, } from "@mui/material"; import { Table, @@ -35,7 +43,7 @@ import { SecurityScope, Meter } from "@/interfaces"; import { useCreateMeter, useGetMeter, useUpdateMeter } from "@/service"; import { formatLatLong } from "@/conversions"; -const MeterResolverSchema: Yup.ObjectSchema = Yup.object().shape({ +const MeterResolverSchema = Yup.object().shape({ serial_number: Yup.string().required("Please enter a serial number."), price: Yup.number().nullable().min(0, "Price cannot be negative"), meter_type: Yup.object().required("Please select a meter type."), @@ -64,12 +72,18 @@ export const MeterDetailsFields = ({ const { handleSubmit, control, - setValue, reset, watch, formState: { errors }, } = useForm({ - resolver: yupResolver(MeterResolverSchema), + resolver: yupResolver(MeterResolverSchema) as unknown as Resolver, + defaultValues: { + contacts: [], + }, + }); + const { fields, append, remove } = useFieldArray({ + control, + name: "contacts", }); function onSuccessfulUpdate() { @@ -82,14 +96,16 @@ export const MeterDetailsFields = ({ const updateMeter = useUpdateMeter(onSuccessfulUpdate); const createMeter = useCreateMeter(onSuccessfulCreate); - const onSaveChanges: SubmitHandler = (data) => { + const onSaveChanges: SubmitHandler = (data) => { updateMeter.mutate(data); }; - const onAddMeter: SubmitHandler = (data) => { - data.well = data.well == "" ? null : data.well; // If no well selected, set to null for API - createMeter.mutate(data); + const onAddMeter: SubmitHandler = (data) => { + createMeter.mutate({ + ...data, + well: data.well || null, + }); }; - const onErr = (data: any) => { + const onErr: SubmitErrorHandler = (data) => { console.log("ERR: ", data); enqueueSnackbar("Please correct any errors before submission.", { variant: "error", @@ -107,10 +123,25 @@ export const MeterDetailsFields = ({ reset(); setIsInitialLoad(false); - Object.entries(meterDetails.data).forEach(([field, value]) => { - setValue(field as any, value); + const contacts = + meterDetails.data.contacts && meterDetails.data.contacts.length > 0 + ? meterDetails.data.contacts + : meterDetails.data.contact_name || meterDetails.data.contact_phone + ? [ + { + name: meterDetails.data.contact_name, + phone: meterDetails.data.contact_phone, + cell: null, + email: null, + address: null, + }, + ] + : []; + reset({ + ...meterDetails.data, + contacts, }); - }, [meterAddMode, selectedMeterID, meterDetails.data, reset, setValue]); + }, [meterAddMode, selectedMeterID, meterDetails.data, reset]); // Empty form if entering add mode useEffect(() => { @@ -305,26 +336,92 @@ export const MeterDetailsFields = ({ disabled={!hasAdminScope || isInitialLoad} /> - - - - - + + + + Contacts + + {hasAdminScope && !isInitialLoad && ( + + + + )} + + {fields.length === 0 ? ( + + No contacts + + ) : ( + fields.map((field, index) => ( + + + + + + + + + + + + + + + + + {hasAdminScope && !isInitialLoad && ( + + remove(index)} + > + + + + )} + + )) + )} .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); +const formatContacts = (contacts: MeterContact[] | undefined) => { + if (!contacts?.length) return "-"; + return contacts + .map((contact) => + [ + contact.name, + contact.phone, + contact.cell, + contact.email, + contact.address, + ] + .filter(Boolean) + .join(" | "), + ) + .join("; "); +}; + +type NotificationSearch = ReturnType & { + created_from?: string; + created_to?: string; + owner_change_request_id?: number; +}; + export const Notifications = () => { const navigate = useNavigate(); const authUser = useAuthUser(); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [ownerChangeSelections, setOwnerChangeSelections] = useState< + Record + >({}); const search = Route.useSearch(); const isAdmin = authUser()?.user_role?.security_scopes?.some( @@ -60,6 +102,12 @@ export const Notifications = () => { const notificationTypesQuery = useGetNotificationTypes(); const rolesQuery = useGetRoles({ enabled: isAdmin }); const usersQuery = useGetUserAdminList({ enabled: isAdmin }); + const ownerChangeRequestsQuery = useGetOwnerChangeRequests({ + enabled: isAdmin, + }); + const acceptOwnerChangeRequest = useAcceptOwnerChangeRequest(); + const rejectOwnerChangeRequest = useRejectOwnerChangeRequest(); + const acceptAllOwnerChangeRequests = useAcceptAllOwnerChangeRequests(); const createNotifications = useCreateNotifications(() => { setIsCreateModalOpen(false); }); @@ -68,8 +116,21 @@ export const Notifications = () => { () => (notificationTypesQuery.data ?? []).map((type) => type.id), [notificationTypesQuery.data], ); - const getAvatarRole = (user: User | null | undefined) => - user ? getRoleLabel(user) : undefined; + const getAvatarRole = useCallback( + (user: User | null | undefined) => (user ? getRoleLabel(user) : undefined), + [], + ); + + const setSearch = useCallback( + (updater: (prev: NotificationSearch) => NotificationSearch) => { + navigate({ + to: "/notifications", + search: (prev) => updater(prev as NotificationSearch), + replace: true, + }); + }, + [navigate], + ); useEffect(() => { if (!notificationTypeIds.length || search.notification_type_id.length) @@ -80,7 +141,25 @@ export const Notifications = () => { notification_type_id: notificationTypeIds, page: 0, })); - }, [notificationTypeIds, search.notification_type_id.length]); + }, [notificationTypeIds, search.notification_type_id.length, setSearch]); + + useEffect(() => { + setOwnerChangeSelections((prev) => { + const next = { ...prev }; + for (const request of ownerChangeRequestsQuery.data ?? []) { + if (!next[request.id]) { + next[request.id] = { + apply_water_users: + request.old_water_users !== request.new_water_users, + apply_contacts: + formatContacts(request.old_contacts) !== + formatContacts(request.new_contacts), + }; + } + } + return next; + }); + }, [ownerChangeRequestsQuery.data]); const notificationsQuery = useGetNotifications({ q: search.q || undefined, @@ -102,14 +181,6 @@ export const Notifications = () => { offset: search.page * search.pageSize, }); - const setSearch = (updater: (prev: typeof search) => any) => { - navigate({ - to: "/notifications", - search: (prev) => updater(prev as typeof search), - replace: true, - }); - }; - const columns = useMemo[]>( () => { const baseColumns: GridColDef[] = [ @@ -397,6 +468,139 @@ export const Notifications = () => { ) : null} + {isAdmin && (ownerChangeRequestsQuery.data?.length ?? 0) > 0 ? ( + + + + + + + + + + + + + + Meter + Current Water Users + OSE Water Users + Current Contacts + OSE Contacts + Water Users + Contacts + Actions + + + + {(ownerChangeRequestsQuery.data ?? []).map( + (request: MeterOwnerChangeRequest) => { + const selected = ownerChangeSelections[request.id] ?? { + apply_water_users: true, + apply_contacts: true, + }; + const isLinkedRequest = + search.owner_change_request_id === request.id; + + return ( + + {request.serial_number} + {request.old_water_users ?? "-"} + {request.new_water_users ?? "-"} + + {formatContacts(request.old_contacts)} + + + {formatContacts(request.new_contacts)} + + + + setOwnerChangeSelections((prev) => ({ + ...prev, + [request.id]: { + ...selected, + apply_water_users: checked, + }, + })) + } + /> + + + + setOwnerChangeSelections((prev) => ({ + ...prev, + [request.id]: { + ...selected, + apply_contacts: checked, + }, + })) + } + /> + + + + + + + ); + }, + )} + +
+
+
+ ) : null} + Date: Fri, 28 Aug 2026 09:39:50 -0500 Subject: [PATCH 3/8] feat(admin-actions): Add ability for admins to do advanced actions --- api/routes/admin.py | 12 +- frontend/src/components/layout/Topbar.tsx | 27 ++++- frontend/src/interfaces/AdminActionResults.ts | 14 +++ frontend/src/interfaces/index.ts | 1 + frontend/src/routeTree.gen.ts | 21 ++++ frontend/src/routes/admin-actions.tsx | 11 ++ frontend/src/service/adminActions.ts | 62 +++++++++++ frontend/src/service/index.ts | 1 + frontend/src/views/AdminActions.tsx | 104 ++++++++++++++++++ frontend/src/views/index.ts | 1 + 10 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 frontend/src/interfaces/AdminActionResults.ts create mode 100644 frontend/src/routes/admin-actions.tsx create mode 100644 frontend/src/service/adminActions.ts create mode 100644 frontend/src/views/AdminActions.tsx diff --git a/api/routes/admin.py b/api/routes/admin.py index cfae7fce..56428e64 100644 --- a/api/routes/admin.py +++ b/api/routes/admin.py @@ -1179,12 +1179,12 @@ async def download_db_backup(file_name: str): return StreamingResponse(file_obj, media_type=content_type, headers=headers) -@admin_router.api_route( - "/backup-db/", - methods=["BACKUP"], - tags=["Admin"], - dependencies=[Depends(ScopedUser.Admin)], -) +@admin_router.api_route( + "/backup-db/", + methods=["BACKUP", "POST"], + tags=["Admin"], + dependencies=[Depends(ScopedUser.Admin)], +) def backup_and_send(): if not BUCKET_NAME: raise ValueError("GCP_BUCKET_NAME environment variable is not set") diff --git a/frontend/src/components/layout/Topbar.tsx b/frontend/src/components/layout/Topbar.tsx index c942ed9d..1bb5e8fa 100644 --- a/frontend/src/components/layout/Topbar.tsx +++ b/frontend/src/components/layout/Topbar.tsx @@ -11,6 +11,8 @@ import { MenuItem, Toolbar, Typography, + SxProps, + Theme, useMediaQuery, useTheme, } from "@mui/material"; @@ -19,6 +21,7 @@ import MenuIcon from "@mui/icons-material/Menu"; import CloseIcon from "@mui/icons-material/Close"; import { ExpandMore, + AdminPanelSettingsOutlined, Home, Logout, MonitorHeart, @@ -58,7 +61,7 @@ export const Topbar = ({ open: boolean; sidebarWidth: number; onMenuClick: () => void; - sx?: any; + sx?: SxProps; }) => { const theme = useTheme(); const isDesktop = useMediaQuery(theme.breakpoints.up("md")); @@ -71,6 +74,7 @@ export const Topbar = ({ const isMonitoringWellsActive = useIsActiveRoute("/monitoringwells"); const isNotificationsActive = useIsActiveRoute("/notifications"); const isSettingsActive = useIsActiveRoute("/settings"); + const isAdminActionsActive = useIsActiveRoute("/admin-actions"); const [userMenuAnchorEl, setUserMenuAnchorEl] = useState( null, @@ -86,6 +90,10 @@ export const Topbar = ({ const email = user?.email ?? "No email available"; const isLoggedIn = !!user; const isImpersonating = !!impersonation; + const isAdmin = + user?.user_role?.security_scopes?.some( + (scope: { scope_string: string }) => scope.scope_string === "admin", + ) ?? false; const impersonationLabel = impersonation?.impersonatedUser.full_name ?? user?.full_name ?? "Unknown User"; const impersonatorLabel = @@ -504,6 +512,23 @@ export const Topbar = ({ Notifications + {isAdmin ? ( + { + navigate({ to: "/admin-actions" }); + handleMenuClose(); + }} + sx={{ minHeight: 36, gap: 1, px: 1.5 }} + > + + + + + Admin Actions + + + ) : null} rootRouteImport, } as any) +const AdminActionsRoute = AdminActionsRouteImport.update({ + id: '/admin-actions', + path: '/admin-actions', + getParentRoute: () => rootRouteImport, +} as any) const ActivitiesRoute = ActivitiesRouteImport.update({ id: '/activities', path: '/activities', @@ -177,6 +183,7 @@ const ActivitiesActivity_idPhotosPhoto_file_nameRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute '/activities': typeof ActivitiesRouteWithChildren + '/admin-actions': typeof AdminActionsRoute '/chlorides': typeof ChloridesRoute '/login': typeof LoginRoute '/monitoringwells': typeof MonitoringwellsRoute @@ -206,6 +213,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/activities': typeof ActivitiesRouteWithChildren + '/admin-actions': typeof AdminActionsRoute '/chlorides': typeof ChloridesRoute '/login': typeof LoginRoute '/monitoringwells': typeof MonitoringwellsRoute @@ -235,6 +243,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/activities': typeof ActivitiesRouteWithChildren + '/admin-actions': typeof AdminActionsRoute '/chlorides': typeof ChloridesRoute '/login': typeof LoginRoute '/monitoringwells': typeof MonitoringwellsRoute @@ -266,6 +275,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/activities' + | '/admin-actions' | '/chlorides' | '/login' | '/monitoringwells' @@ -295,6 +305,7 @@ export interface FileRouteTypes { to: | '/' | '/activities' + | '/admin-actions' | '/chlorides' | '/login' | '/monitoringwells' @@ -323,6 +334,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/activities' + | '/admin-actions' | '/chlorides' | '/login' | '/monitoringwells' @@ -353,6 +365,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute ActivitiesRoute: typeof ActivitiesRouteWithChildren + AdminActionsRoute: typeof AdminActionsRoute ChloridesRoute: typeof ChloridesRoute LoginRoute: typeof LoginRoute MonitoringwellsRoute: typeof MonitoringwellsRoute @@ -421,6 +434,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChloridesRouteImport parentRoute: typeof rootRouteImport } + '/admin-actions': { + id: '/admin-actions' + path: '/admin-actions' + fullPath: '/admin-actions' + preLoaderRoute: typeof AdminActionsRouteImport + parentRoute: typeof rootRouteImport + } '/activities': { id: '/activities' path: '/activities' @@ -601,6 +621,7 @@ const ManagePartsRouteWithChildren = ManagePartsRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ActivitiesRoute: ActivitiesRouteWithChildren, + AdminActionsRoute: AdminActionsRoute, ChloridesRoute: ChloridesRoute, LoginRoute: LoginRoute, MonitoringwellsRoute: MonitoringwellsRoute, diff --git a/frontend/src/routes/admin-actions.tsx b/frontend/src/routes/admin-actions.tsx new file mode 100644 index 00000000..7466b53a --- /dev/null +++ b/frontend/src/routes/admin-actions.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { AdminActions } from "@/views/AdminActions"; +import { ProtectedRoute } from "@/ProtectedRoute"; + +export const Route = createFileRoute("/admin-actions")({ + component: () => ( + + + + ), +}); diff --git a/frontend/src/service/adminActions.ts b/frontend/src/service/adminActions.ts new file mode 100644 index 00000000..2f0e8a94 --- /dev/null +++ b/frontend/src/service/adminActions.ts @@ -0,0 +1,62 @@ +import { useSnackbar } from "notistack"; +import { useMutation, useQueryClient } from "react-query"; +import { useApiClient } from "@/hooks"; +import { BackupDbResult, OSEOwnerSyncResult } from "@/interfaces"; + +export function useRunOSEOwnerSync() { + const { enqueueSnackbar } = useSnackbar(); + const queryClient = useQueryClient(); + const apiClient = useApiClient(); + + return useMutation({ + mutationFn: async () => { + const response = await apiClient.post("admin/ose-owner-sync", {}); + + if (!response.ok) { + const errorMessage = + (await response.json().catch(() => null))?.detail ?? + `Error ${response.status}`; + enqueueSnackbar(errorMessage, { variant: "error" }); + throw Error(errorMessage); + } + + return (await response.json()) as OSEOwnerSyncResult; + }, + onSuccess: (result) => { + queryClient.invalidateQueries("notifications"); + queryClient.invalidateQueries("notifications/unread_count"); + queryClient.invalidateQueries("admin/ose-owner-change-requests"); + enqueueSnackbar(`Created ${result.created_request_count} owner changes.`, { + variant: "success", + }); + }, + retry: 0, + }); +} + +export function useCreateDatabaseBackup() { + const { enqueueSnackbar } = useSnackbar(); + const queryClient = useQueryClient(); + const apiClient = useApiClient(); + + return useMutation({ + mutationFn: async () => { + const response = await apiClient.post("backup-db/", {}); + + if (!response.ok) { + const errorMessage = + (await response.json().catch(() => null))?.detail ?? + `Error ${response.status}`; + enqueueSnackbar(errorMessage, { variant: "error" }); + throw Error(errorMessage); + } + + return (await response.json()) as BackupDbResult; + }, + onSuccess: () => { + queryClient.invalidateQueries("db-backups"); + enqueueSnackbar("Database backup created.", { variant: "success" }); + }, + retry: 0, + }); +} diff --git a/frontend/src/service/index.ts b/frontend/src/service/index.ts index 7c9d90fc..69665cef 100644 --- a/frontend/src/service/index.ts +++ b/frontend/src/service/index.ts @@ -1,3 +1,4 @@ +export * from "./adminActions"; export * from "./lookups"; export * from "./meters"; export * from "./measurements"; diff --git a/frontend/src/views/AdminActions.tsx b/frontend/src/views/AdminActions.tsx new file mode 100644 index 00000000..0c5ed121 --- /dev/null +++ b/frontend/src/views/AdminActions.tsx @@ -0,0 +1,104 @@ +import { + Alert, + AlertTitle, + Box, + Button, + Card, + CardContent, + CircularProgress, + Grid, + Stack, + Typography, +} from "@mui/material"; +import { AdminPanelSettingsOutlined, Backup, Sync } from "@mui/icons-material"; +import { BackgroundBox, CustomCardHeader } from "@/components"; +import { useCreateDatabaseBackup, useRunOSEOwnerSync } from "@/service"; + +export const AdminActions = () => { + const runOSEOwnerSync = useRunOSEOwnerSync(); + const createDatabaseBackup = useCreateDatabaseBackup(); + + return ( + + + + + + + + + OSE Owner Sync + + Fetch OSE meter owner/contact data and create owner-change + notifications for admin review. + + + + + + {runOSEOwnerSync.data ? ( + + Sync Complete + Fetched {runOSEOwnerSync.data.fetched_count}, matched{" "} + {runOSEOwnerSync.data.matched_count}, found{" "} + {runOSEOwnerSync.data.changed_count} changes, created{" "} + {runOSEOwnerSync.data.created_request_count} requests and{" "} + {runOSEOwnerSync.data.notification_count} notifications. + + ) : null} + + + + + + Database Backup + + Create a database backup and upload it to the configured + backup bucket. + + + + + + {createDatabaseBackup.data ? ( + + Backup Complete + {createDatabaseBackup.data.status} + + ) : null} + + + + + + + ); +}; diff --git a/frontend/src/views/index.ts b/frontend/src/views/index.ts index ec225209..668a9b1a 100644 --- a/frontend/src/views/index.ts +++ b/frontend/src/views/index.ts @@ -1,4 +1,5 @@ export * from "./Activities"; +export * from "./AdminActions"; export * from "./Backups"; export * from "./Home"; export * from "./InsufficientPermView"; From c08aadcd87da2da33394e617521c6fad176f0d96 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 28 Aug 2026 13:30:16 -0500 Subject: [PATCH 4/8] feat(Notifications): update owner change UI --- api/routes/admin.py | 19 +- frontend/src/service/notifications.ts | 6 + frontend/src/views/Notifications.tsx | 1238 +++++++++++++++---------- 3 files changed, 782 insertions(+), 481 deletions(-) diff --git a/api/routes/admin.py b/api/routes/admin.py index 56428e64..4c790bb5 100644 --- a/api/routes/admin.py +++ b/api/routes/admin.py @@ -9,7 +9,7 @@ from fastapi import Depends, APIRouter, HTTPException, Query, Request from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session, joinedload, undefer -from sqlalchemy import select +from sqlalchemy import select, update from typing import List from api.models.meter import MeterContacts, MeterOwnerChangeRequests, Meters @@ -218,6 +218,21 @@ def _create_owner_change_notifications( return len(notifications) +def _mark_owner_change_notifications_read( + db: Session, + change_request: MeterOwnerChangeRequests, +) -> None: + db.execute( + update(Notifications) + .where( + Notifications.link + == f"/notifications?owner_change_request_id={change_request.id}", + Notifications.is_read.is_(False), + ) + .values(is_read=True, read_at=datetime.now()) + ) + + def _apply_owner_change_request( db: Session, change_request: MeterOwnerChangeRequests, @@ -251,6 +266,7 @@ def _apply_owner_change_request( change_request.resolved_at = datetime.now(timezone.utc) db.add(meter) db.add(change_request) + _mark_owner_change_notifications_read(db, change_request) return change_request @@ -510,6 +526,7 @@ def reject_ose_owner_change_request( change_request.resolved_by = current_admin.id change_request.resolved_at = datetime.now(timezone.utc) db.add(change_request) + _mark_owner_change_notifications_read(db, change_request) db.commit() db.refresh(change_request) return change_request diff --git a/frontend/src/service/notifications.ts b/frontend/src/service/notifications.ts index 8e9173f9..27e0d665 100644 --- a/frontend/src/service/notifications.ts +++ b/frontend/src/service/notifications.ts @@ -170,6 +170,8 @@ export function useAcceptOwnerChangeRequest( }, onSuccess: (result) => { queryClient.invalidateQueries("admin/ose-owner-change-requests"); + queryClient.invalidateQueries("notifications"); + queryClient.invalidateQueries("notifications/unread_count"); queryClient.invalidateQueries("meter"); queryClient.invalidateQueries("meters"); enqueueSnackbar("Owner change applied.", { variant: "success" }); @@ -205,6 +207,8 @@ export function useRejectOwnerChangeRequest( }, onSuccess: (result) => { queryClient.invalidateQueries("admin/ose-owner-change-requests"); + queryClient.invalidateQueries("notifications"); + queryClient.invalidateQueries("notifications/unread_count"); enqueueSnackbar("Owner change rejected.", { variant: "success" }); onSuccess?.(result); }, @@ -236,6 +240,8 @@ export function useAcceptAllOwnerChangeRequests( }, onSuccess: (result) => { queryClient.invalidateQueries("admin/ose-owner-change-requests"); + queryClient.invalidateQueries("notifications"); + queryClient.invalidateQueries("notifications/unread_count"); queryClient.invalidateQueries("meter"); queryClient.invalidateQueries("meters"); enqueueSnackbar(`Applied ${result.accepted_count} owner changes.`, { diff --git a/frontend/src/views/Notifications.tsx b/frontend/src/views/Notifications.tsx index fc56d1d3..4ceae6b2 100644 --- a/frontend/src/views/Notifications.tsx +++ b/frontend/src/views/Notifications.tsx @@ -19,10 +19,16 @@ import { TableCell, TableContainer, TableHead, + TablePagination, TableRow, TextField, } from "@mui/material"; -import { Add, NotificationsOutlined, Search, TaskAlt } from "@mui/icons-material"; +import { + Add, + NotificationsOutlined, + Search, + TaskAlt, +} from "@mui/icons-material"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { DatePicker } from "@mui/x-date-pickers"; import { useNavigate } from "@tanstack/react-router"; @@ -81,6 +87,99 @@ const formatContacts = (contacts: MeterContact[] | undefined) => { .join("; "); }; +type OwnerChangeDiffLine = { + prefix: "+" | "-" | " " | "@"; + text: string; + field?: "water_users" | "contacts"; +}; + +const contactDiffFields: Array<{ + key: keyof Pick< + MeterContact, + "name" | "phone" | "cell" | "email" | "address" + >; + label: string; +}> = [ + { key: "name", label: "name" }, + { key: "phone", label: "phone" }, + { key: "cell", label: "cell" }, + { key: "email", label: "email" }, + { key: "address", label: "address" }, +]; + +const normalizedDiffValue = (value: string | null | undefined) => + value?.trim() || "-"; + +const buildFieldDiffLines = ( + label: string, + oldValue: string | null | undefined, + newValue: string | null | undefined, + field: OwnerChangeDiffLine["field"], +): OwnerChangeDiffLine[] => { + const currentValue = normalizedDiffValue(oldValue); + const oseValue = normalizedDiffValue(newValue); + + if (currentValue === oseValue) { + return [{ prefix: " ", text: `${label}: ${currentValue}`, field }]; + } + + return [ + { prefix: "-", text: `${label}: ${currentValue}`, field }, + { prefix: "+", text: `${label}: ${oseValue}`, field }, + ]; +}; + +const buildContactsDiffLines = ( + oldContacts: MeterContact[], + newContacts: MeterContact[], +): OwnerChangeDiffLine[] => { + const maxContacts = Math.max(oldContacts.length, newContacts.length); + + if (!maxContacts) { + return [{ prefix: " ", text: "No contacts" }]; + } + + return Array.from({ length: maxContacts }).flatMap((_, index) => { + const oldContact = oldContacts[index]; + const newContact = newContacts[index]; + const contactLabel = `Contact ${index + 1}`; + const fieldLines = contactDiffFields.flatMap(({ key, label }) => { + const oldValue = oldContact?.[key]; + const newValue = newContact?.[key]; + + if (!oldValue && !newValue) return []; + + return buildFieldDiffLines( + `${contactLabel} ${label}`, + oldValue, + newValue, + "contacts", + ); + }); + + if (!fieldLines.length) return []; + + return [ + { prefix: "@", text: contactLabel, field: "contacts" }, + ...fieldLines, + ]; + }); +}; + +const buildOwnerChangeDiffLines = ( + request: MeterOwnerChangeRequest, +): OwnerChangeDiffLine[] => [ + { prefix: "@", text: "Water users", field: "water_users" }, + ...buildFieldDiffLines( + "water users", + request.old_water_users, + request.new_water_users, + "water_users", + ), + { prefix: "@", text: "Contacts", field: "contacts" }, + ...buildContactsDiffLines(request.old_contacts, request.new_contacts), +]; + type NotificationSearch = ReturnType & { created_from?: string; created_to?: string; @@ -94,6 +193,8 @@ export const Notifications = () => { const [ownerChangeSelections, setOwnerChangeSelections] = useState< Record >({}); + const [ownerChangePage, setOwnerChangePage] = useState(0); + const [ownerChangePageSize, setOwnerChangePageSize] = useState(5); const search = Route.useSearch(); const isAdmin = authUser()?.user_role?.security_scopes?.some( @@ -120,6 +221,18 @@ export const Notifications = () => { (user: User | null | undefined) => (user ? getRoleLabel(user) : undefined), [], ); + const ownerChangeRequests = useMemo( + () => ownerChangeRequestsQuery.data ?? [], + [ownerChangeRequestsQuery.data], + ); + const paginatedOwnerChangeRequests = useMemo( + () => + ownerChangeRequests.slice( + ownerChangePage * ownerChangePageSize, + ownerChangePage * ownerChangePageSize + ownerChangePageSize, + ), + [ownerChangePage, ownerChangePageSize, ownerChangeRequests], + ); const setSearch = useCallback( (updater: (prev: NotificationSearch) => NotificationSearch) => { @@ -161,6 +274,40 @@ export const Notifications = () => { }); }, [ownerChangeRequestsQuery.data]); + useEffect(() => { + const lastPage = Math.max( + 0, + Math.ceil(ownerChangeRequests.length / ownerChangePageSize) - 1, + ); + + if (ownerChangePage > lastPage) { + setOwnerChangePage(lastPage); + } + }, [ownerChangePage, ownerChangePageSize, ownerChangeRequests.length]); + + useEffect(() => { + if (!search.owner_change_request_id) return; + + const linkedRequestIndex = ownerChangeRequests.findIndex( + (request) => request.id === search.owner_change_request_id, + ); + + if (linkedRequestIndex === -1) return; + + const linkedRequestPage = Math.floor( + linkedRequestIndex / ownerChangePageSize, + ); + + if (linkedRequestPage !== ownerChangePage) { + setOwnerChangePage(linkedRequestPage); + } + }, [ + ownerChangePage, + ownerChangePageSize, + ownerChangeRequests, + search.owner_change_request_id, + ]); + const notificationsQuery = useGetNotifications({ q: search.q || undefined, is_read: @@ -181,511 +328,642 @@ export const Notifications = () => { offset: search.page * search.pageSize, }); - const columns = useMemo[]>( - () => { - const baseColumns: GridColDef[] = [ - { - field: "read_toggle", - headerName: "Mark Read", - minWidth: 110, - flex: 0.7, - sortable: false, - filterable: false, - renderCell: (params) => ( - - updateNotificationReadStatus.mutate({ - id: params.row.id, - is_read: checked, - }) - } - /> - ), - }, - { - field: "created_at", - headerName: "Created", - minWidth: 190, - flex: 1.1, - valueFormatter: (value) => - value ? dayjs(value as string).format("MMMM D, YYYY h:mm A") : "-", - }, - { - field: "notification_type", - headerName: "Type", - minWidth: 140, - flex: 0.9, - sortable: false, - valueGetter: (_, row) => row.notification_type?.name ?? "", - renderCell: (params) => ( - - ), - }, - { - field: "is_read", - headerName: "Status", - minWidth: 110, - flex: 0.7, - renderCell: (params) => ( - - ), - }, - { - field: "title", - headerName: "Title", - minWidth: 220, - flex: 1.4, - }, - { - field: "message", - headerName: "Message", - minWidth: 320, - flex: 2.3, - }, - { - field: "link", - headerName: "Link", - minWidth: 180, - flex: 1.2, - sortable: false, - renderCell: (params) => { - const value = params.value as string | null | undefined; - if (!value) return "-"; - - return ( - - Open - - ); - }, + const columns = useMemo[]>(() => { + const baseColumns: GridColDef[] = [ + { + field: "read_toggle", + headerName: "Mark Read", + minWidth: 110, + flex: 0.7, + sortable: false, + filterable: false, + renderCell: (params) => ( + + updateNotificationReadStatus.mutate({ + id: params.row.id, + is_read: checked, + }) + } + /> + ), + }, + { + field: "created_at", + headerName: "Created", + minWidth: 190, + flex: 1.1, + valueFormatter: (value) => + value ? dayjs(value as string).format("MMMM D, YYYY h:mm A") : "-", + }, + { + field: "notification_type", + headerName: "Type", + minWidth: 140, + flex: 0.9, + sortable: false, + valueGetter: (_, row) => row.notification_type?.name ?? "", + renderCell: (params) => ( + + ), + }, + { + field: "is_read", + headerName: "Status", + minWidth: 110, + flex: 0.7, + renderCell: (params) => ( + + ), + }, + { + field: "title", + headerName: "Title", + minWidth: 220, + flex: 1.4, + }, + { + field: "message", + headerName: "Message", + minWidth: 320, + flex: 2.3, + }, + { + field: "link", + headerName: "Link", + minWidth: 180, + flex: 1.2, + sortable: false, + renderCell: (params) => { + const value = params.value as string | null | undefined; + if (!value) return "-"; + + return ( + + Open + + ); }, - ]; - - if (!isAdmin) return baseColumns; - - return [ - baseColumns[0], - baseColumns[1], - { - field: "creator", - headerName: "Created By", - minWidth: 220, - flex: 1.3, - sortable: false, - cellClassName: "notification-creator-cell", - valueGetter: (_, row) => - row.creator?.display_name || row.creator?.full_name || "", - renderCell: (params) => { - const creator = params.row.creator; - if (!creator) return "-"; - - const name = creator.display_name || creator.full_name || "Unknown"; - - return ( - - - {name} - - ); - }, + }, + ]; + + if (!isAdmin) return baseColumns; + + return [ + baseColumns[0], + baseColumns[1], + { + field: "creator", + headerName: "Created By", + minWidth: 220, + flex: 1.3, + sortable: false, + cellClassName: "notification-creator-cell", + valueGetter: (_, row) => + row.creator?.display_name || row.creator?.full_name || "", + renderCell: (params) => { + const creator = params.row.creator; + if (!creator) return "-"; + + const name = creator.display_name || creator.full_name || "Unknown"; + + return ( + + + {name} + + ); }, - ...baseColumns.slice(2), - ]; - }, - [getAvatarRole, isAdmin, updateNotificationReadStatus], - ); + }, + ...baseColumns.slice(2), + ]; + }, [getAvatarRole, isAdmin, updateNotificationReadStatus]); return ( - - - - - - - setSearch((prev) => ({ - ...prev, - created_from: - value && value.isValid() - ? value.format("YYYY-MM-DD") - : undefined, - page: 0, - })) - } - views={["year", "month", "day"]} - openTo="year" - format="YYYY MMMM DD" - slotProps={{ textField: { size: "small", fullWidth: true } }} - /> - - - - setSearch((prev) => ({ - ...prev, - created_to: - value && value.isValid() - ? value.format("YYYY-MM-DD") - : undefined, - page: 0, - })) - } - views={["year", "month", "day"]} - openTo="year" - format="YYYY MMMM DD" - slotProps={{ textField: { size: "small", fullWidth: true } }} - /> - - - - Type - + setSearch((prev) => ({ + ...prev, + notification_type_id: ( + e.target.value as number[] + ).map(Number), + page: 0, + })) + } + renderValue={(selected) => + (selected as number[]) + .map( + (id) => + notificationTypesQuery.data?.find( + (type) => type.id === id, + )?.name ?? "", + ) + .filter(Boolean) + .map(formatNotificationTypeName) + .join(", ") + } + > + {(notificationTypesQuery.data ?? []).map((type) => ( + + {formatNotificationTypeName(type.name)} + + ))} + + + + + + + setSearch((prev) => ({ + ...prev, + q: e.target.value, + page: 0, + })) + } + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + - {(notificationTypesQuery.data ?? []).map((type) => ( - - {formatNotificationTypeName(type.name)} - - ))} - - - - - - + + setSearch((prev) => ({ + ...prev, + is_read: next, + page: 0, + })) + } + /> + + + + {notificationsQuery.error ? ( + + Failed to load notifications. + + ) : null} + + + setSearch((prev) => ({ ...prev, - q: e.target.value, - page: 0, + pageSize: model.pageSize, + page: model.pageSize !== prev.pageSize ? 0 : model.page, })) } - InputProps={{ - startAdornment: ( - - - - ), + disableRowSelectionOnClick + disableColumnMenu + getRowHeight={() => "auto"} + sx={{ + "& .notification-creator-cell": { + alignItems: "flex-start", + py: 1, + }, + "& .MuiDataGrid-cell": { + py: 1.25, + }, }} /> - -
- - - setSearch((prev) => ({ - ...prev, - is_read: next, - page: 0, - })) - } - /> - -
- - {notificationsQuery.error ? ( - - Failed to load notifications. - - ) : null} - - {isAdmin && (ownerChangeRequestsQuery.data?.length ?? 0) > 0 ? ( - + - - - + {isAdmin ? ( + + + + ) : null} - - - - - Meter - Current Water Users - OSE Water Users - Current Contacts - OSE Contacts - Water Users - Contacts - Actions - - - - {(ownerChangeRequestsQuery.data ?? []).map( - (request: MeterOwnerChangeRequest) => { - const selected = ownerChangeSelections[request.id] ?? { - apply_water_users: true, - apply_contacts: true, - }; - const isLinkedRequest = - search.owner_change_request_id === request.id; - - return ( - - {request.serial_number} - {request.old_water_users ?? "-"} - {request.new_water_users ?? "-"} - - {formatContacts(request.old_contacts)} - - - {formatContacts(request.new_contacts)} - - - - setOwnerChangeSelections((prev) => ({ - ...prev, - [request.id]: { - ...selected, - apply_water_users: checked, - }, - })) - } - /> - - - - setOwnerChangeSelections((prev) => ({ - ...prev, - [request.id]: { - ...selected, - apply_contacts: checked, - }, - })) - } - /> - - - - - - - ); - }, - )} - -
-
- - ) : null} + {isAdmin ? ( + setIsCreateModalOpen(false)} + users={usersQuery.data ?? []} + roles={rolesQuery.data ?? []} + notificationTypes={notificationTypesQuery.data ?? []} + loading={createNotifications.isLoading} + onSubmit={(payload) => createNotifications.mutate(payload)} + /> + ) : null} + + + - - - setSearch((prev) => ({ - ...prev, - pageSize: model.pageSize, - page: model.pageSize !== prev.pageSize ? 0 : model.page, - })) - } - disableRowSelectionOnClick - disableColumnMenu - getRowHeight={() => "auto"} - sx={{ - "& .notification-creator-cell": { - alignItems: "flex-start", - py: 1, - }, - "& .MuiDataGrid-cell": { - py: 1.25, - }, - }} - /> - - - - - - {isAdmin ? ( - - - - ) : null} - - {isAdmin ? ( - setIsCreateModalOpen(false)} - users={usersQuery.data ?? []} - roles={rolesQuery.data ?? []} - notificationTypes={notificationTypesQuery.data ?? []} - loading={createNotifications.isLoading} - onSubmit={(payload) => createNotifications.mutate(payload)} - /> + + + + + Meter + Diff + Apply + Actions + + + + {paginatedOwnerChangeRequests.map( + (request: MeterOwnerChangeRequest) => { + const selected = ownerChangeSelections[ + request.id + ] ?? { + apply_water_users: true, + apply_contacts: true, + }; + const isLinkedRequest = + search.owner_change_request_id === request.id; + const diffLines = + buildOwnerChangeDiffLines(request); + + return ( + + {request.serial_number} + + + {diffLines.map((line, index) => { + const isLineApplied = + line.field === "water_users" + ? selected.apply_water_users + : line.field === "contacts" + ? selected.apply_contacts + : true; + + return ( + + + {line.prefix === "@" + ? "@@" + : line.prefix} + + + {line.text} + + + ); + })} + + + + + + + setOwnerChangeSelections((prev) => ({ + ...prev, + [request.id]: { + ...selected, + apply_water_users: checked, + }, + })) + } + /> + Water users + + + + setOwnerChangeSelections((prev) => ({ + ...prev, + [request.id]: { + ...selected, + apply_contacts: checked, + }, + })) + } + /> + Contacts + + + + + + + + + ); + }, + )} + +
+
+ setOwnerChangePage(page)} + onRowsPerPageChange={(event) => { + setOwnerChangePageSize(Number(event.target.value)); + setOwnerChangePage(0); + }} + sx={{ + borderTop: "1px solid", + borderColor: "divider", + }} + /> + + + ) : null} - - + + ); }; From 55e1bbc635dc574f369977152dca9909f562bc4e Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 28 Aug 2026 13:37:57 -0500 Subject: [PATCH 5/8] fix(Notifications): update UI to notify user if no new owner changes --- frontend/src/views/Notifications.tsx | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/frontend/src/views/Notifications.tsx b/frontend/src/views/Notifications.tsx index 4ceae6b2..9d27596c 100644 --- a/frontend/src/views/Notifications.tsx +++ b/frontend/src/views/Notifications.tsx @@ -50,7 +50,6 @@ import { import { Route } from "@/routes/notifications"; import { useCreateNotifications, - useAcceptAllOwnerChangeRequests, useAcceptOwnerChangeRequest, useGetNotifications, useGetNotificationTypes, @@ -208,7 +207,6 @@ export const Notifications = () => { }); const acceptOwnerChangeRequest = useAcceptOwnerChangeRequest(); const rejectOwnerChangeRequest = useRejectOwnerChangeRequest(); - const acceptAllOwnerChangeRequests = useAcceptAllOwnerChangeRequests(); const createNotifications = useCreateNotifications(() => { setIsCreateModalOpen(false); }); @@ -715,8 +713,8 @@ export const Notifications = () => { - - {isAdmin && ownerChangeRequests.length > 0 ? ( + {isAdmin ? ( + @@ -751,6 +749,17 @@ export const Notifications = () => { + {ownerChangeRequests.length === 0 ? ( + + + No rows + + + ) : null} {paginatedOwnerChangeRequests.map( (request: MeterOwnerChangeRequest) => { const selected = ownerChangeSelections[ @@ -953,16 +962,12 @@ export const Notifications = () => { setOwnerChangePageSize(Number(event.target.value)); setOwnerChangePage(0); }} - sx={{ - borderTop: "1px solid", - borderColor: "divider", - }} /> - ) : null} - + + ) : null} ); From 613e2aa14eb45d16e4285dc272d8ab4efb73702f Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 28 Aug 2026 13:51:01 -0500 Subject: [PATCH 6/8] feat(Notifications): swap table for datagrid in owner changes review section --- frontend/src/views/Notifications.tsx | 544 ++++++++++++++------------ scripts/create_dummy_owner_changes.py | 314 +++++++++++++++ 2 files changed, 601 insertions(+), 257 deletions(-) create mode 100644 scripts/create_dummy_owner_changes.py diff --git a/frontend/src/views/Notifications.tsx b/frontend/src/views/Notifications.tsx index 9d27596c..af260da8 100644 --- a/frontend/src/views/Notifications.tsx +++ b/frontend/src/views/Notifications.tsx @@ -14,13 +14,6 @@ import { InputLabel, MenuItem, Select, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TablePagination, - TableRow, TextField, } from "@mui/material"; import { @@ -223,14 +216,6 @@ export const Notifications = () => { () => ownerChangeRequestsQuery.data ?? [], [ownerChangeRequestsQuery.data], ); - const paginatedOwnerChangeRequests = useMemo( - () => - ownerChangeRequests.slice( - ownerChangePage * ownerChangePageSize, - ownerChangePage * ownerChangePageSize + ownerChangePageSize, - ), - [ownerChangePage, ownerChangePageSize, ownerChangeRequests], - ); const setSearch = useCallback( (updater: (prev: NotificationSearch) => NotificationSearch) => { @@ -464,6 +449,243 @@ export const Notifications = () => { ]; }, [getAvatarRole, isAdmin, updateNotificationReadStatus]); + const ownerChangeColumns = useMemo[]>( + () => [ + { + field: "serial_number", + headerName: "Meter", + minWidth: 120, + flex: 0.7, + }, + { + field: "diff", + headerName: "Diff", + minWidth: 520, + flex: 2.8, + sortable: false, + filterable: false, + cellClassName: "owner-change-top-cell", + renderCell: (params) => { + const selected = ownerChangeSelections[params.row.id] ?? { + apply_water_users: true, + apply_contacts: true, + }; + const diffLines = buildOwnerChangeDiffLines(params.row); + + return ( + + {diffLines.map((line, index) => { + const isLineApplied = + line.field === "water_users" + ? selected.apply_water_users + : line.field === "contacts" + ? selected.apply_contacts + : true; + + return ( + + + {line.prefix === "@" ? "@@" : line.prefix} + + + {line.text} + + + ); + })} + + ); + }, + }, + { + field: "apply", + headerName: "Apply", + minWidth: 170, + flex: 0.8, + sortable: false, + filterable: false, + cellClassName: "owner-change-top-cell", + renderCell: (params) => { + const selected = ownerChangeSelections[params.row.id] ?? { + apply_water_users: true, + apply_contacts: true, + }; + + return ( + + + + setOwnerChangeSelections((prev) => ({ + ...prev, + [params.row.id]: { + ...selected, + apply_water_users: checked, + }, + })) + } + /> + Water users + + + + setOwnerChangeSelections((prev) => ({ + ...prev, + [params.row.id]: { + ...selected, + apply_contacts: checked, + }, + })) + } + /> + Contacts + + + ); + }, + }, + { + field: "actions", + headerName: "Actions", + minWidth: 170, + flex: 0.8, + sortable: false, + filterable: false, + align: "right", + headerAlign: "right", + renderCell: (params) => { + const selected = ownerChangeSelections[params.row.id] ?? { + apply_water_users: true, + apply_contacts: true, + }; + + return ( + + + + + ); + }, + }, + ], + [ + acceptOwnerChangeRequest, + ownerChangeSelections, + rejectOwnerChangeRequest, + ], + ); + return ( { - - - + { + setOwnerChangePageSize(model.pageSize); + setOwnerChangePage( + model.pageSize !== ownerChangePageSize + ? 0 + : model.page, + ); + }} + disableRowSelectionOnClick + rowSelection={false} + disableColumnMenu + getRowHeight={() => "auto"} + getRowClassName={(params) => + search.owner_change_request_id === params.row.id + ? "owner-change-linked-row" + : "" + } + sx={{ + "& .owner-change-top-cell": { + alignItems: "flex-start", + py: 1, + }, + "& .MuiDataGrid-cell": { + py: 1.25, + outline: "none", + }, + "& .MuiDataGrid-cell:focus, & .MuiDataGrid-cell:focus-within": { + outline: "none", + }, + "& .MuiDataGrid-row.Mui-selected, & .MuiDataGrid-row.Mui-selected:hover": + { + bgcolor: "transparent", }, - }} - > - - - Meter - Diff - Apply - Actions - - - - {ownerChangeRequests.length === 0 ? ( - - - No rows - - - ) : null} - {paginatedOwnerChangeRequests.map( - (request: MeterOwnerChangeRequest) => { - const selected = ownerChangeSelections[ - request.id - ] ?? { - apply_water_users: true, - apply_contacts: true, - }; - const isLinkedRequest = - search.owner_change_request_id === request.id; - const diffLines = - buildOwnerChangeDiffLines(request); - - return ( - - {request.serial_number} - - - {diffLines.map((line, index) => { - const isLineApplied = - line.field === "water_users" - ? selected.apply_water_users - : line.field === "contacts" - ? selected.apply_contacts - : true; - - return ( - - - {line.prefix === "@" - ? "@@" - : line.prefix} - - - {line.text} - - - ); - })} - - - - - - - setOwnerChangeSelections((prev) => ({ - ...prev, - [request.id]: { - ...selected, - apply_water_users: checked, - }, - })) - } - /> - Water users - - - - setOwnerChangeSelections((prev) => ({ - ...prev, - [request.id]: { - ...selected, - apply_contacts: checked, - }, - })) - } - /> - Contacts - - - - - - - - - ); - }, - )} - -
-
- setOwnerChangePage(page)} - onRowsPerPageChange={(event) => { - setOwnerChangePageSize(Number(event.target.value)); - setOwnerChangePage(0); + "& .MuiDataGrid-row:hover": { + bgcolor: "transparent", + }, + "& .owner-change-linked-row": { + boxShadow: (theme) => + `inset 3px 0 0 ${theme.palette.primary.main}`, + }, }} />
diff --git a/scripts/create_dummy_owner_changes.py b/scripts/create_dummy_owner_changes.py new file mode 100644 index 00000000..e89b6903 --- /dev/null +++ b/scripts/create_dummy_owner_changes.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +# ruff: noqa: E402 +"""Create dummy meter owner-change requests for UI testing. + +Run from the repository root. Set APPDB_ENV first if you normally use one, for +example: + + APPDB_ENV=.env_devserver uv run python scripts/create_dummy_owner_changes.py --clear +""" + +from __future__ import annotations + +import argparse +import sys +from datetime import datetime, timedelta +from itertools import cycle +from pathlib import Path + +from sqlalchemy import delete, select +from sqlalchemy.orm import joinedload + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +from api.models import ( + MeterOwnerChangeRequests, + Meters, + NotificationTypeLU, + Notifications, + SecurityScopes, + UserRoles, + Users, +) +from api.session import SessionLocal + + +DUMMY_OSE_METER_ID_START = 900_000_000 +DUMMY_OSE_METER_ID_END = 900_999_999 + + +CONTACT_NAMES = [ + "Rio Farms LLC", + "Sanchez Family Trust", + "Pecos Valley Growers", + "North Gate Dairy", + "Luna Irrigation Co.", + "Mesa Ag Partners", +] + +STREETS = [ + "101 Canal Rd", + "2458 County Line Rd", + "77 Orchard Loop", + "910 South Pump Station Rd", + "34 Lateral B", + "682 Cottonwood Ave", +] + + +def contact_snapshot(contact) -> dict[str, str | None]: + return { + "name": contact.name, + "phone": contact.phone, + "cell": contact.cell, + "email": contact.email, + "address": contact.address, + } + + +def existing_contacts(meter: Meters) -> list[dict[str, str | None]]: + contacts = [contact_snapshot(contact) for contact in meter.contacts] + contacts = [contact for contact in contacts if any(contact.values())] + if contacts: + return contacts + + if meter.contact_name or meter.contact_phone: + return [ + { + "name": meter.contact_name, + "phone": meter.contact_phone, + "cell": None, + "email": None, + "address": None, + } + ] + + return [] + + +def new_contacts_for(index: int) -> list[dict[str, str | None]]: + primary_name = CONTACT_NAMES[index % len(CONTACT_NAMES)] + secondary_name = CONTACT_NAMES[(index + 2) % len(CONTACT_NAMES)] + phone_suffix = f"{1000 + index:04d}" + return [ + { + "name": primary_name, + "phone": f"575-555-{phone_suffix}", + "cell": f"575-777-{phone_suffix}", + "email": f"owner-change-{index + 1}@example.test", + "address": f"{STREETS[index % len(STREETS)]}, Artesia, NM 88210", + }, + { + "name": secondary_name, + "phone": None, + "cell": f"575-888-{phone_suffix}", + "email": f"billing-{index + 1}@example.test", + "address": None, + }, + ] + + +def admin_user_ids(db) -> list[int]: + return db.scalars( + select(Users.id) + .join(UserRoles, Users.user_role_id == UserRoles.id) + .join(UserRoles.security_scopes) + .where(SecurityScopes.scope_string == "admin", Users.disabled.is_(False)) + ).all() + + +def first_enabled_user_id(db) -> int | None: + return db.scalar( + select(Users.id).where(Users.disabled.is_(False)).order_by(Users.id).limit(1) + ) + + +def owner_change_notification_type_id(db) -> int | None: + return db.scalar( + select(NotificationTypeLU.id).where(NotificationTypeLU.name == "owner_change") + ) + + +def clear_dummy_rows(db) -> tuple[int, int]: + dummy_request_ids = db.scalars( + select(MeterOwnerChangeRequests.id).where( + MeterOwnerChangeRequests.ose_meter_id.between( + DUMMY_OSE_METER_ID_START, + DUMMY_OSE_METER_ID_END, + ) + ) + ).all() + + deleted_notifications = 0 + if dummy_request_ids: + links = [ + f"/notifications?owner_change_request_id={request_id}" + for request_id in dummy_request_ids + ] + deleted_notifications = ( + db.execute(delete(Notifications).where(Notifications.link.in_(links))) + .rowcount + or 0 + ) + + deleted_requests = ( + db.execute( + delete(MeterOwnerChangeRequests).where( + MeterOwnerChangeRequests.ose_meter_id.between( + DUMMY_OSE_METER_ID_START, + DUMMY_OSE_METER_ID_END, + ) + ) + ).rowcount + or 0 + ) + return deleted_requests, deleted_notifications + + +def create_notifications(db, change_requests, created_by: int | None) -> int: + notification_type_id = owner_change_notification_type_id(db) + user_ids = admin_user_ids(db) + if not notification_type_id or not user_ids: + return 0 + + notifications = [] + for change_request in change_requests: + if change_request.status not in {"pending", "partially_accepted"}: + continue + for user_id in user_ids: + notifications.append( + Notifications( + user_id=user_id, + notification_type_id=notification_type_id, + created_by=created_by, + title=f"Owner Change: Meter {change_request.serial_number}", + message=( + "OSE owner or contact information differs from Meter Manager. " + "Review and accept the selected changes." + ), + link=f"/notifications?owner_change_request_id={change_request.id}", + ) + ) + + db.add_all(notifications) + return len(notifications) + + +def create_dummy_owner_changes( + count: int, + clear: bool, + include_resolved: bool, + with_notifications: bool, +) -> None: + with SessionLocal() as db: + if clear: + deleted_requests, deleted_notifications = clear_dummy_rows(db) + print( + "Cleared " + f"{deleted_requests} dummy owner-change request(s) and " + f"{deleted_notifications} notification(s)." + ) + + meters = db.scalars( + select(Meters) + .options(joinedload(Meters.contacts)) + .order_by(Meters.id) + .limit(count) + ).unique().all() + if not meters: + raise SystemExit("No meters found. Seed or import meters before running this.") + + created_by = first_enabled_user_id(db) + now = datetime.now() + statuses = ["pending", "partially_accepted"] + if include_resolved: + statuses.extend(["accepted", "rejected"]) + + created_requests = [] + for index, (meter, status) in enumerate(zip(meters, cycle(statuses))): + old_water_users = meter.water_users or meter.meter_owner or "PVACD" + new_water_users = f"{CONTACT_NAMES[index % len(CONTACT_NAMES)]}; Test Owner {index + 1}" + old_contacts = existing_contacts(meter) + if not old_contacts: + old_contacts = [ + { + "name": meter.meter_owner or "PVACD", + "phone": meter.contact_phone, + "cell": None, + "email": None, + "address": None, + } + ] + + resolved = status in {"accepted", "rejected"} + change_request = MeterOwnerChangeRequests( + meter_id=meter.id, + serial_number=meter.serial_number, + ose_meter_id=DUMMY_OSE_METER_ID_START + index, + old_water_users=old_water_users, + new_water_users=new_water_users, + old_contacts=old_contacts, + new_contacts=new_contacts_for(index), + status=status, + created_by=created_by, + resolved_by=created_by if resolved else None, + created_at=now - timedelta(hours=index), + resolved_at=now - timedelta(minutes=index * 10) if resolved else None, + ) + db.add(change_request) + created_requests.append(change_request) + + db.flush() + notification_count = ( + create_notifications(db, created_requests, created_by) + if with_notifications + else 0 + ) + db.commit() + + print( + f"Created {len(created_requests)} dummy owner-change request(s) " + f"and {notification_count} notification(s)." + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Create dummy meter owner-change requests for UI testing." + ) + parser.add_argument( + "--count", + type=int, + default=8, + help="Number of owner-change requests to create. Defaults to 8.", + ) + parser.add_argument( + "--clear", + action="store_true", + help="Delete previously-created dummy owner-change requests first.", + ) + parser.add_argument( + "--include-resolved", + action="store_true", + help="Also create accepted and rejected rows. The default UI query hides these.", + ) + parser.add_argument( + "--no-notifications", + action="store_true", + help="Create owner-change requests without matching notification rows.", + ) + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_args() + if args.count < 1: + raise SystemExit("--count must be at least 1") + + create_dummy_owner_changes( + count=args.count, + clear=args.clear, + include_resolved=args.include_resolved, + with_notifications=not args.no_notifications, + ) From e682d372de356ce1d9b279db02a9f6bf8eb2a5e3 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 28 Aug 2026 14:00:34 -0500 Subject: [PATCH 7/8] fix(migrations): rm phone, cell, and email from meter_contacts --- api/models/meter.py | 3 -- api/routes/admin.py | 15 +------- api/routes/meters.py | 13 ++----- api/schemas/admin.py | 3 -- api/schemas/meter.py | 3 -- frontend/src/interfaces/MeterContact.ts | 3 -- .../src/views/Meters/MeterDetailsFields.tsx | 34 ++----------------- frontend/src/views/Notifications.tsx | 13 ++----- ...ve_meter_contact_phone_cell_email.down.sql | 4 +++ ...move_meter_contact_phone_cell_email.up.sql | 4 +++ scripts/create_dummy_owner_changes.py | 18 +--------- 11 files changed, 16 insertions(+), 97 deletions(-) create mode 100644 migrations/20260828001000_remove_meter_contact_phone_cell_email.down.sql create mode 100644 migrations/20260828001000_remove_meter_contact_phone_cell_email.up.sql diff --git a/api/models/meter.py b/api/models/meter.py index 642ff791..168fdb31 100644 --- a/api/models/meter.py +++ b/api/models/meter.py @@ -103,9 +103,6 @@ class MeterContacts(Base): Integer, ForeignKey("Meters.id", ondelete="CASCADE", onupdate="CASCADE"), index=True ) name: Mapped[Optional[str]] = mapped_column(String) - phone: Mapped[Optional[str]] = mapped_column(String) - cell: Mapped[Optional[str]] = mapped_column(String) - email: Mapped[Optional[str]] = mapped_column(String) address: Mapped[Optional[str]] = mapped_column(String) meter: Mapped["Meters"] = relationship("Meters", back_populates="contacts") diff --git a/api/routes/admin.py b/api/routes/admin.py index 4c790bb5..a43f10a6 100644 --- a/api/routes/admin.py +++ b/api/routes/admin.py @@ -117,9 +117,6 @@ def _contacts_from_ose_meter(ose_meter: dict) -> list[dict]: contacts.append( { "name": contact.get("name"), - "phone": contact.get("phone"), - "cell": contact.get("cell"), - "email": contact.get("email"), "address": contact.get("address"), } ) @@ -131,21 +128,15 @@ def _contacts_from_meter(meter: Meters) -> list[dict]: return [ { "name": contact.name, - "phone": contact.phone, - "cell": contact.cell, - "email": contact.email, "address": contact.address, } for contact in meter.contacts ] - if meter.contact_name or meter.contact_phone: + if meter.contact_name: return [ { "name": meter.contact_name, - "phone": meter.contact_phone, - "cell": None, - "email": None, "address": None, } ] @@ -161,16 +152,12 @@ def _set_meter_contacts(meter: Meters, contacts: list[dict]) -> None: meter.contacts.append( MeterContacts( name=contact.get("name"), - phone=contact.get("phone"), - cell=contact.get("cell"), - email=contact.get("email"), address=contact.get("address"), ) ) first_contact = next((contact for contact in contacts if any(contact.values())), None) meter.contact_name = first_contact.get("name") if first_contact else None - meter.contact_phone = first_contact.get("phone") if first_contact else None def _admin_user_ids(db: Session) -> list[int]: diff --git a/api/routes/meters.py b/api/routes/meters.py index e5382fd9..d7d10ae2 100644 --- a/api/routes/meters.py +++ b/api/routes/meters.py @@ -33,9 +33,6 @@ def _contact_has_value(contact: meter.MeterContact) -> bool: return any( [ contact.name, - contact.phone, - contact.cell, - contact.email, contact.address, ] ) @@ -51,9 +48,6 @@ def _replace_meter_contacts( meter_db.contacts.append( MeterContacts( name=contact.name, - phone=contact.phone, - cell=contact.cell, - email=contact.email, address=contact.address, ) ) @@ -62,7 +56,6 @@ def _replace_meter_contacts( (contact for contact in contacts if _contact_has_value(contact)), None ) meter_db.contact_name = first_contact.name if first_contact else None - meter_db.contact_phone = first_contact.phone if first_contact else None db.add(meter_db) @@ -331,11 +324,10 @@ def create_meter(new_meter: meter.SubmitNewMeter, db: Session = Depends(get_db)) new_meter_model.location_id = new_meter.well.location_id contacts = new_meter.contacts - if not contacts and (new_meter.contact_name or new_meter.contact_phone): + if not contacts and new_meter.contact_name: contacts = [ meter.MeterContact( name=new_meter.contact_name, - phone=new_meter.contact_phone, ) ] _replace_meter_contacts(db, new_meter_model, contacts) @@ -687,11 +679,10 @@ def patch_meter(updated_meter: meter.SubmitMeterUpdate, db: Session = Depends(ge meter_db.status_id = updated_meter.status.id contacts = updated_meter.contacts - if not contacts and (updated_meter.contact_name or updated_meter.contact_phone): + if not contacts and updated_meter.contact_name: contacts = [ meter.MeterContact( name=updated_meter.contact_name, - phone=updated_meter.contact_phone, ) ] _replace_meter_contacts(db, meter_db, contacts) diff --git a/api/schemas/admin.py b/api/schemas/admin.py index 8cffc21c..02410e85 100644 --- a/api/schemas/admin.py +++ b/api/schemas/admin.py @@ -15,9 +15,6 @@ class BackupFile(ORMBase): class MeterContactSnapshot(BaseModel): name: str | None = None - phone: str | None = None - cell: str | None = None - email: str | None = None address: str | None = None diff --git a/api/schemas/meter.py b/api/schemas/meter.py index 025b67f4..ea156363 100644 --- a/api/schemas/meter.py +++ b/api/schemas/meter.py @@ -91,9 +91,6 @@ class MeterStatusLU(ORMBase): class MeterContact(ORMBase): meter_id: int | None = None name: str | None = None - phone: str | None = None - cell: str | None = None - email: str | None = None address: str | None = None diff --git a/frontend/src/interfaces/MeterContact.ts b/frontend/src/interfaces/MeterContact.ts index a391cc7d..240d31f7 100644 --- a/frontend/src/interfaces/MeterContact.ts +++ b/frontend/src/interfaces/MeterContact.ts @@ -2,8 +2,5 @@ export interface MeterContact { id?: number | null; meter_id?: number | null; name?: string | null; - phone?: string | null; - cell?: string | null; - email?: string | null; address?: string | null; } diff --git a/frontend/src/views/Meters/MeterDetailsFields.tsx b/frontend/src/views/Meters/MeterDetailsFields.tsx index 8c30d383..35f1a091 100644 --- a/frontend/src/views/Meters/MeterDetailsFields.tsx +++ b/frontend/src/views/Meters/MeterDetailsFields.tsx @@ -130,9 +130,6 @@ export const MeterDetailsFields = ({ ? [ { name: meterDetails.data.contact_name, - phone: meterDetails.data.contact_phone, - cell: null, - email: null, address: null, }, ] @@ -349,9 +346,6 @@ export const MeterDetailsFields = ({ onClick={() => append({ name: "", - phone: "", - cell: "", - email: "", address: "", }) } @@ -369,7 +363,7 @@ export const MeterDetailsFields = ({ ) : ( fields.map((field, index) => ( - + - - - - - - - - - - + { if (!contacts?.length) return "-"; return contacts .map((contact) => - [ - contact.name, - contact.phone, - contact.cell, - contact.email, - contact.address, - ] + [contact.name, contact.address] .filter(Boolean) .join(" | "), ) @@ -88,14 +82,11 @@ type OwnerChangeDiffLine = { const contactDiffFields: Array<{ key: keyof Pick< MeterContact, - "name" | "phone" | "cell" | "email" | "address" + "name" | "address" >; label: string; }> = [ { key: "name", label: "name" }, - { key: "phone", label: "phone" }, - { key: "cell", label: "cell" }, - { key: "email", label: "email" }, { key: "address", label: "address" }, ]; diff --git a/migrations/20260828001000_remove_meter_contact_phone_cell_email.down.sql b/migrations/20260828001000_remove_meter_contact_phone_cell_email.down.sql new file mode 100644 index 00000000..0a1a95ea --- /dev/null +++ b/migrations/20260828001000_remove_meter_contact_phone_cell_email.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE public.meter_contacts + ADD COLUMN IF NOT EXISTS phone varchar NULL, + ADD COLUMN IF NOT EXISTS cell varchar NULL, + ADD COLUMN IF NOT EXISTS email varchar NULL; diff --git a/migrations/20260828001000_remove_meter_contact_phone_cell_email.up.sql b/migrations/20260828001000_remove_meter_contact_phone_cell_email.up.sql new file mode 100644 index 00000000..1a89acd6 --- /dev/null +++ b/migrations/20260828001000_remove_meter_contact_phone_cell_email.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE public.meter_contacts + DROP COLUMN IF EXISTS phone, + DROP COLUMN IF EXISTS cell, + DROP COLUMN IF EXISTS email; diff --git a/scripts/create_dummy_owner_changes.py b/scripts/create_dummy_owner_changes.py index e89b6903..dfbb3802 100644 --- a/scripts/create_dummy_owner_changes.py +++ b/scripts/create_dummy_owner_changes.py @@ -60,9 +60,6 @@ def contact_snapshot(contact) -> dict[str, str | None]: return { "name": contact.name, - "phone": contact.phone, - "cell": contact.cell, - "email": contact.email, "address": contact.address, } @@ -73,13 +70,10 @@ def existing_contacts(meter: Meters) -> list[dict[str, str | None]]: if contacts: return contacts - if meter.contact_name or meter.contact_phone: + if meter.contact_name: return [ { "name": meter.contact_name, - "phone": meter.contact_phone, - "cell": None, - "email": None, "address": None, } ] @@ -90,20 +84,13 @@ def existing_contacts(meter: Meters) -> list[dict[str, str | None]]: def new_contacts_for(index: int) -> list[dict[str, str | None]]: primary_name = CONTACT_NAMES[index % len(CONTACT_NAMES)] secondary_name = CONTACT_NAMES[(index + 2) % len(CONTACT_NAMES)] - phone_suffix = f"{1000 + index:04d}" return [ { "name": primary_name, - "phone": f"575-555-{phone_suffix}", - "cell": f"575-777-{phone_suffix}", - "email": f"owner-change-{index + 1}@example.test", "address": f"{STREETS[index % len(STREETS)]}, Artesia, NM 88210", }, { "name": secondary_name, - "phone": None, - "cell": f"575-888-{phone_suffix}", - "email": f"billing-{index + 1}@example.test", "address": None, }, ] @@ -234,9 +221,6 @@ def create_dummy_owner_changes( old_contacts = [ { "name": meter.meter_owner or "PVACD", - "phone": meter.contact_phone, - "cell": None, - "email": None, "address": None, } ] From 900559160d449048fdf1693b2430bb263d494f2a Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 28 Aug 2026 14:14:54 -0500 Subject: [PATCH 8/8] feat(AdminActions): add info and warning labels to the admin page --- frontend/src/views/AdminActions.tsx | 51 +++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/AdminActions.tsx b/frontend/src/views/AdminActions.tsx index 0c5ed121..6f2ff19f 100644 --- a/frontend/src/views/AdminActions.tsx +++ b/frontend/src/views/AdminActions.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Alert, AlertTitle, @@ -17,11 +18,16 @@ import { useCreateDatabaseBackup, useRunOSEOwnerSync } from "@/service"; export const AdminActions = () => { const runOSEOwnerSync = useRunOSEOwnerSync(); const createDatabaseBackup = useCreateDatabaseBackup(); + const [oseSyncWarningAcknowledged, setOSESyncWarningAcknowledged] = + useState(false); return ( - + @@ -33,6 +39,34 @@ export const AdminActions = () => { notifications for admin review. + setOSESyncWarningAcknowledged(true)} + > + {oseSyncWarningAcknowledged + ? "Acknowledged" + : "Acknowledge"} + + } + > + Review Before Running + OSE owner sync is an expensive operation that takes several + minutes to complete. Do not change pages or close this tab + while the sync is running. + + + Recommended Schedule + Run OSE owner sync once per month, preferably at the beginning + of the month. + {runOSEOwnerSync.data ? ( @@ -70,6 +109,12 @@ export const AdminActions = () => { backup bucket. + + Review Before Running + Automatic production database backups are performed daily. + Test database backups are performed weekly. Use this action if + you require an additional on-demand backup. +