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..168fdb31 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,51 @@ 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) + 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..a43f10a6 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 sqlalchemy import select, update 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,204 @@ 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"), + "address": contact.get("address"), + } + ) + return contacts + + +def _contacts_from_meter(meter: Meters) -> list[dict]: + if meter.contacts: + return [ + { + "name": contact.name, + "address": contact.address, + } + for contact in meter.contacts + ] + + if meter.contact_name: + return [ + { + "name": meter.contact_name, + "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"), + 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 + + +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 _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, + 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) + _mark_owner_change_notifications_read(db, 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 +323,231 @@ 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) + _mark_owner_change_notifications_read(db, 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, @@ -748,12 +1183,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/api/routes/meters.py b/api/routes/meters.py index 9a33a8aa..d7d10ae2 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,36 @@ public_meter_router = APIRouter() +def _contact_has_value(contact: meter.MeterContact) -> bool: + return any( + [ + contact.name, + 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, + 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 + db.add(meter_db) + + # Get paginated, sorted list of meters, filtered by a search string if applicable @authenticated_meter_router.get( "/meters", @@ -292,6 +323,15 @@ 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: + contacts = [ + meter.MeterContact( + name=new_meter.contact_name, + ) + ] + _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 +535,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 +546,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 +654,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 +678,15 @@ 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: + contacts = [ + meter.MeterContact( + name=updated_meter.contact_name, + ) + ] + _replace_meter_contacts(db, meter_db, contacts) + try: db.add(meter_db) db.commit() @@ -651,9 +699,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..02410e85 100644 --- a/api/schemas/admin.py +++ b/api/schemas/admin.py @@ -11,3 +11,42 @@ class BackupFile(ORMBase): format: str gs_uri: str created_utc: Optional[datetime.datetime] = None + + +class MeterContactSnapshot(BaseModel): + name: 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..ea156363 100644 --- a/api/schemas/meter.py +++ b/api/schemas/meter.py @@ -88,6 +88,12 @@ class MeterStatusLU(ORMBase): description: str | None = None +class MeterContact(ORMBase): + meter_id: int | None = None + name: str | None = None + address: str | None = None + + class NoteTypeLU(ORMBase): note: str | None = None details: str | None = None @@ -100,6 +106,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 +117,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 +132,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 +145,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/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), ) ) ) 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/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/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/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/service/notifications.ts b/frontend/src/service/notifications.ts index e243c3ea..27e0d665 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,131 @@ 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("notifications"); + queryClient.invalidateQueries("notifications/unread_count"); + 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"); + queryClient.invalidateQueries("notifications"); + queryClient.invalidateQueries("notifications/unread_count"); + 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("notifications"); + queryClient.invalidateQueries("notifications/unread_count"); + 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/AdminActions.tsx b/frontend/src/views/AdminActions.tsx new file mode 100644 index 00000000..6f2ff19f --- /dev/null +++ b/frontend/src/views/AdminActions.tsx @@ -0,0 +1,149 @@ +import { useState } from "react"; +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(); + const [oseSyncWarningAcknowledged, setOSESyncWarningAcknowledged] = + useState(false); + + return ( + + + + + + + + + OSE Owner Sync + + Fetch OSE meter owner/contact data and create owner-change + 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 ? ( + + 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. + + + + 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. + + + + + {createDatabaseBackup.data ? ( + + Backup Complete + {createDatabaseBackup.data.status} + + ) : null} + + + + + + + ); +}; diff --git a/frontend/src/views/Meters/MeterDetailsFields.tsx b/frontend/src/views/Meters/MeterDetailsFields.tsx index 0f578c5c..35f1a091 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,22 @@ 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, + 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 +333,65 @@ 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.address] + .filter(Boolean) + .join(" | "), + ) + .join("; "); +}; + +type OwnerChangeDiffLine = { + prefix: "+" | "-" | " " | "@"; + text: string; + field?: "water_users" | "contacts"; +}; + +const contactDiffFields: Array<{ + key: keyof Pick< + MeterContact, + "name" | "address" + >; + label: string; +}> = [ + { key: "name", label: "name" }, + { 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; + 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 [ownerChangePage, setOwnerChangePage] = useState(0); + const [ownerChangePageSize, setOwnerChangePageSize] = useState(5); const search = Route.useSearch(); const isAdmin = authUser()?.user_role?.security_scopes?.some( @@ -60,6 +186,11 @@ 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 createNotifications = useCreateNotifications(() => { setIsCreateModalOpen(false); }); @@ -68,8 +199,25 @@ 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 ownerChangeRequests = useMemo( + () => ownerChangeRequestsQuery.data ?? [], + [ownerChangeRequestsQuery.data], + ); + + 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 +228,59 @@ 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]); + + 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, @@ -102,386 +302,694 @@ 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[] = [ - { - 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, + 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 + + ); }, - { - field: "message", - headerName: "Message", - minWidth: 320, - flex: 2.3, + }, + ]; + + 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} + + ); }, - { - 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 - - ); - }, + }, + ...baseColumns.slice(2), + ]; + }, [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} + + + ); + })} + + ); }, - ]; - - 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 ( + }, + { + 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 ( + - - {name} - - ); - }, - }, - ...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 - - - - - - + Water users + + + - setSearch((prev) => ({ + checked={selected.apply_contacts} + onChange={(_, checked) => + setOwnerChangeSelections((prev) => ({ ...prev, - q: e.target.value, - page: 0, + [params.row.id]: { + ...selected, + apply_contacts: checked, + }, })) } - InputProps={{ - startAdornment: ( - - - - ), - }} /> - - - + + ); + }, + }, + { + 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 ( + - - setSearch((prev) => ({ - ...prev, - is_read: next, - page: 0, - })) - } - /> - - - - {notificationsQuery.error ? ( - - Failed to load notifications. - - ) : 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 ? ( - - + + ); + }, + }, + ], + [ + acceptOwnerChangeRequest, + ownerChangeSelections, + rejectOwnerChangeRequest, + ], + ); + + 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, + q: e.target.value, + page: 0, + })) + } + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + - Create - + + setSearch((prev) => ({ + ...prev, + is_read: next, + page: 0, + })) + } + /> + - ) : null} + + {notificationsQuery.error ? ( + + Failed to load notifications. + + ) : 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)} + /> + ) : null} + + + + + {isAdmin ? ( + + + + + + { + 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", + }, + "& .MuiDataGrid-row:hover": { + bgcolor: "transparent", + }, + "& .owner-change-linked-row": { + boxShadow: (theme) => + `inset 3px 0 0 ${theme.palette.primary.main}`, + }, + }} + /> + + + - {isAdmin ? ( - setIsCreateModalOpen(false)} - users={usersQuery.data ?? []} - roles={rolesQuery.data ?? []} - notificationTypes={notificationTypesQuery.data ?? []} - loading={createNotifications.isLoading} - onSubmit={(payload) => createNotifications.mutate(payload)} - /> - ) : null} - - + ) : 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"; diff --git a/migrations/20260828000000_add_meter_contacts_and_owner_change_requests.down.sql b/migrations/20260828000000_add_meter_contacts_and_owner_change_requests.down.sql new file mode 100644 index 00000000..e06f51e8 --- /dev/null +++ b/migrations/20260828000000_add_meter_contacts_and_owner_change_requests.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS public.meter_owner_change_requests; +DROP TABLE IF EXISTS public.meter_contacts; diff --git a/migrations/20260828000000_add_meter_contacts_and_owner_change_requests.up.sql b/migrations/20260828000000_add_meter_contacts_and_owner_change_requests.up.sql new file mode 100644 index 00000000..60aaebcc --- /dev/null +++ b/migrations/20260828000000_add_meter_contacts_and_owner_change_requests.up.sql @@ -0,0 +1,70 @@ +CREATE TABLE public.meter_contacts ( + id serial4 NOT NULL, + meter_id int4 NOT NULL, + name varchar NULL, + phone varchar NULL, + cell varchar NULL, + email varchar NULL, + address text NULL, + CONSTRAINT meter_contacts_pkey PRIMARY KEY (id), + CONSTRAINT fk_meter_contacts_meter + FOREIGN KEY (meter_id) + REFERENCES public."Meters"(id) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE INDEX ix_meter_contacts_id + ON public.meter_contacts USING btree (id); + +CREATE INDEX ix_meter_contacts_meter_id + ON public.meter_contacts USING btree (meter_id); + +INSERT INTO public.meter_contacts (meter_id, name, phone) +SELECT id, contact_name, contact_phone +FROM public."Meters" +WHERE NULLIF(TRIM(contact_name), '') IS NOT NULL + OR NULLIF(TRIM(contact_phone), '') IS NOT NULL; + +CREATE TABLE public.meter_owner_change_requests ( + id serial4 NOT NULL, + meter_id int4 NOT NULL, + serial_number varchar NOT NULL, + ose_meter_id int4 NULL, + old_water_users text NULL, + new_water_users text NULL, + old_contacts jsonb NOT NULL DEFAULT '[]'::jsonb, + new_contacts jsonb NOT NULL DEFAULT '[]'::jsonb, + status varchar(20) NOT NULL DEFAULT 'pending', + created_by int4 NULL, + resolved_by int4 NULL, + created_at timestamp NOT NULL DEFAULT now(), + resolved_at timestamp NULL, + CONSTRAINT meter_owner_change_requests_pkey PRIMARY KEY (id), + CONSTRAINT meter_owner_change_requests_status_check + CHECK (status IN ('pending', 'accepted', 'partially_accepted', 'rejected')), + CONSTRAINT fk_meter_owner_change_requests_meter + FOREIGN KEY (meter_id) + REFERENCES public."Meters"(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT fk_meter_owner_change_requests_created_by + FOREIGN KEY (created_by) + REFERENCES public."Users"(id) + ON DELETE SET NULL + ON UPDATE CASCADE, + CONSTRAINT fk_meter_owner_change_requests_resolved_by + FOREIGN KEY (resolved_by) + REFERENCES public."Users"(id) + ON DELETE SET NULL + ON UPDATE CASCADE +); + +CREATE INDEX ix_meter_owner_change_requests_id + ON public.meter_owner_change_requests USING btree (id); + +CREATE INDEX ix_meter_owner_change_requests_meter_id + ON public.meter_owner_change_requests USING btree (meter_id); + +CREATE INDEX ix_meter_owner_change_requests_status + ON public.meter_owner_change_requests USING btree (status); 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 new file mode 100644 index 00000000..dfbb3802 --- /dev/null +++ b/scripts/create_dummy_owner_changes.py @@ -0,0 +1,298 @@ +#!/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, + "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: + return [ + { + "name": meter.contact_name, + "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)] + return [ + { + "name": primary_name, + "address": f"{STREETS[index % len(STREETS)]}, Artesia, NM 88210", + }, + { + "name": secondary_name, + "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", + "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, + )