diff --git a/api/main.py b/api/main.py index b89c6411..befc01cd 100644 --- a/api/main.py +++ b/api/main.py @@ -6,7 +6,7 @@ from starlette import status from api.schemas import security_schemas from api.models.main_models import Users -from api.routes.activities import activity_router +from api.routes.activities import activity_router, public_activity_router from api.routes.admin import admin_router from api.routes.chlorides import authenticated_chlorides_router, public_chlorides_router from api.routes.maintenance import maintenance_router @@ -14,7 +14,10 @@ from api.routes.OSE import ose_router from api.routes.parts import part_router from api.routes.settings import settings_router -from api.routes.well_measurements import authenticated_well_measurement_router, public_well_measurement_router +from api.routes.well_measurements import ( + authenticated_well_measurement_router, + public_well_measurement_router, +) from api.routes.wells import authenticated_well_router, public_well_router from api.security import ( authenticate_user, @@ -123,6 +126,7 @@ def login_for_access_token( add_pagination(app) app.include_router(ose_router) +app.include_router(public_activity_router) app.include_router(public_meter_router) app.include_router(public_well_router) app.include_router(public_chlorides_router) diff --git a/api/models/main_models.py b/api/models/main_models.py index ea192863..4892cc78 100644 --- a/api/models/main_models.py +++ b/api/models/main_models.py @@ -162,7 +162,9 @@ class Meters(Base): location_id: Mapped[int] = mapped_column( Integer, ForeignKey("Locations.id"), nullable=False ) - register_id: Mapped[int] = mapped_column(Integer, ForeignKey("meter_registers.id"), nullable=True) + register_id: Mapped[int] = mapped_column( + Integer, ForeignKey("meter_registers.id"), nullable=True + ) water_users: Mapped[Optional[str]] = mapped_column(String) meter_owner: Mapped[Optional[str]] = mapped_column(String) @@ -174,7 +176,6 @@ class Meters(Base): location: Mapped["Locations"] = relationship() - class MeterTypeLU(Base): """ Meter types @@ -238,14 +239,23 @@ class MeterActivities(Base): ) notes: Mapped[List["NoteTypeLU"]] = relationship("NoteTypeLU", secondary=Notes) work_order: Mapped["workOrders"] = relationship() - well: Mapped["Wells"] = relationship("Wells", primaryjoin='MeterActivities.location_id == Wells.location_id', foreign_keys='MeterActivities.location_id', viewonly=True) - photos: Mapped[List["MeterActivityPhotos"]] = relationship("MeterActivityPhotos", back_populates="meter_activity", cascade="all, delete") + well: Mapped["Wells"] = relationship( + "Wells", + primaryjoin="MeterActivities.location_id == Wells.location_id", + foreign_keys="MeterActivities.location_id", + viewonly=True, + ) + photos: Mapped[List["MeterActivityPhotos"]] = relationship( + "MeterActivityPhotos", back_populates="meter_activity", cascade="all, delete" + ) class MeterActivityPhotos(Base): __tablename__ = "MeterActivityPhotos" - id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True, autoincrement=True) + id: Mapped[int] = mapped_column( + Integer, primary_key=True, index=True, autoincrement=True + ) meter_activity_id: Mapped[int] = mapped_column( Integer, ForeignKey("MeterActivities.id", ondelete="CASCADE"), nullable=False ) @@ -254,7 +264,7 @@ class MeterActivityPhotos(Base): uploaded_at: Mapped[DateTime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) - + original_file_name = Column(String, nullable=True) meter_activity: Mapped["MeterActivities"] = relationship( "MeterActivities", back_populates="photos" ) @@ -488,6 +498,7 @@ class WellUseLU(Base): code: Mapped[str] = mapped_column(String) description: Mapped[str] = mapped_column(String) + class WaterSources(Base): """ The source of water for a well @@ -497,6 +508,7 @@ class WaterSources(Base): name: Mapped[str] = mapped_column(String, nullable=False) description: Mapped[str] = mapped_column(String) + class WellStatus(Base): """ The status of a well @@ -525,7 +537,9 @@ class Wells(Base): use_type_id: Mapped[int] = mapped_column(Integer, ForeignKey("WellUseLU.id")) location_id: Mapped[int] = mapped_column(Integer, ForeignKey("Locations.id")) - water_source_id: Mapped[int] = mapped_column(Integer, ForeignKey("water_sources.id")) + water_source_id: Mapped[int] = mapped_column( + Integer, ForeignKey("water_sources.id") + ) well_status_id: Mapped[int] = mapped_column(Integer, ForeignKey("well_status.id")) chloride_group_id: Mapped[int] = mapped_column(Integer) @@ -568,52 +582,67 @@ class WellMeasurements(Base): class workOrderStatusLU(Base): - ''' + """ Models the status of a work order - ''' + """ + __tablename__ = "work_order_status_lu" name = mapped_column(String, nullable=False) description = mapped_column(String, nullable=False) + class workOrders(Base): - ''' + """ Models work orders and associated information - ''' + """ + __tablename__ = "work_orders" date_created: Mapped[DateTime] = mapped_column(DateTime, nullable=False) - creator: Mapped[str] = mapped_column(String, nullable=True) # There is no consistent list of persons for this, so it is nullable + creator: Mapped[str] = mapped_column( + String, nullable=True + ) # There is no consistent list of persons for this, so it is nullable title: Mapped[str] = mapped_column(String, nullable=False) description: Mapped[str] = mapped_column(String, nullable=True) - meter_id: Mapped[int] = mapped_column(Integer, ForeignKey("Meters.id"), nullable=False) - status_id: Mapped[int] = mapped_column(Integer, ForeignKey("work_order_status_lu.id"), nullable=False) + meter_id: Mapped[int] = mapped_column( + Integer, ForeignKey("Meters.id"), nullable=False + ) + status_id: Mapped[int] = mapped_column( + Integer, ForeignKey("work_order_status_lu.id"), nullable=False + ) notes: Mapped[str] = mapped_column(String, nullable=True) - assigned_user_id: Mapped[int] = mapped_column(Integer, ForeignKey("Users.id"), nullable=True) + assigned_user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("Users.id"), nullable=True + ) ose_request_id: Mapped[int] = mapped_column(Integer, nullable=True) # Associated Activities # associated_activities: Mapped[List['MeterActivities']] = relationship("MeterActivities") - meter: Mapped['Meters']= relationship() - status: Mapped['workOrderStatusLU']= relationship() - assigned_user: Mapped['Users'] = relationship() + meter: Mapped["Meters"] = relationship() + status: Mapped["workOrderStatusLU"] = relationship() + assigned_user: Mapped["Users"] = relationship() + class meterRegisters(Base): - ''' + """ Models the registers of a meter - ''' + """ + __tablename__ = "meter_registers" brand: Mapped[str] = mapped_column(String, nullable=False) meter_size: Mapped[float] = mapped_column(Float, nullable=False) part_id: Mapped[int] = mapped_column(Integer, ForeignKey("Parts.id")) ratio: Mapped[str] = mapped_column(String) - dial_units_id: Mapped[int] = mapped_column(Integer, ForeignKey("Units.id"), nullable=False) - totalizer_units_id: Mapped[int] = mapped_column(Integer, ForeignKey("Units.id"), nullable=False) + dial_units_id: Mapped[int] = mapped_column( + Integer, ForeignKey("Units.id"), nullable=False + ) + totalizer_units_id: Mapped[int] = mapped_column( + Integer, ForeignKey("Units.id"), nullable=False + ) number_of_digits: Mapped[int] = mapped_column(Integer, nullable=False) decimal_digits: Mapped[int] = mapped_column(Integer) multiplier: Mapped[float] = mapped_column(Float, nullable=False) notes: Mapped[str] = mapped_column(String) - dial_units: Mapped['Units'] = relationship(foreign_keys=[dial_units_id]) - totalizer_units: Mapped['Units'] = relationship(foreign_keys=[totalizer_units_id]) - - + dial_units: Mapped["Units"] = relationship(foreign_keys=[dial_units_id]) + totalizer_units: Mapped["Units"] = relationship(foreign_keys=[totalizer_units_id]) diff --git a/api/routes/OSE.py b/api/routes/OSE.py index f14b60a1..d4e90589 100644 --- a/api/routes/OSE.py +++ b/api/routes/OSE.py @@ -1,9 +1,9 @@ from datetime import datetime, date, time -from pydantic import BaseModel +from pydantic import BaseModel, Field from fastapi import Depends, APIRouter, HTTPException, Query from sqlalchemy import select, and_ -from sqlalchemy.orm import Session, joinedload +from sqlalchemy.orm import Session, joinedload, selectinload from api.models.main_models import ( Meters, @@ -16,16 +16,27 @@ ObservedPropertyTypeLU, ServiceTypeLU, NoteTypeLU, - MeterStatusLU + MeterStatusLU, ) from api.schemas import meter_schemas from api.session import get_db from api.enums import ScopedUser +import os + + +API_BASE_URL = os.getenv("API_BASE_URL", "") + + ose_router = APIRouter(dependencies=[Depends(ScopedUser.OSE)]) +class MeterActivityPhotoDTO(BaseModel): + name: str + url: str + + class ObservationDTO(BaseModel): observation_time: time # Will be associated with a given activity observation_type: str @@ -42,15 +53,16 @@ class ActivityDTO(BaseModel): well_ra_number: str | None well_ose_tag: str | None description: str - services: list[str] - notes: list[str] - parts_used: list[str] - observations: list[ObservationDTO] - + services: list[str] = Field(default_factory=list) + notes: list[str] = Field(default_factory=list) + parts_used: list[str] = Field(default_factory=list) + observations: list[ObservationDTO] = Field(default_factory=list) + meter_activity_photos: list[MeterActivityPhotoDTO] = Field(default_factory=list) + class MeterHistoryDTO(BaseModel): serial_number: str - activities: list[ActivityDTO] + activities: list[ActivityDTO] = Field(default_factory=list) class DateHistoryDTO(BaseModel): @@ -59,9 +71,10 @@ class DateHistoryDTO(BaseModel): class DisapprovalStatus(BaseModel): - ''' + """ Returns the status of a disapproval request and response - ''' + """ + ose_request_id: int status: str notes: str | None = None @@ -69,6 +82,10 @@ class DisapprovalStatus(BaseModel): new_activities: list[ActivityDTO] | None = None +def build_activity_photo_url(activity_id: int, photo_name: str) -> str: + return f"{API_BASE_URL}/activities/{activity_id}/photos/{photo_name}" + + def getObservations( activity_start: datetime, activity_end: datetime, @@ -95,7 +112,10 @@ def getObservations( return observations_list -def reorganizeHistory(activities: list[MeterActivities], observations: list[MeterObservations]) -> list[DateHistoryDTO]: + +def reorganizeHistory( + activities: list[MeterActivities], observations: list[MeterObservations] +) -> list[DateHistoryDTO]: """ A function to reorganize the data into the desired format for OSE history """ @@ -149,9 +169,19 @@ def reorganizeHistory(activities: list[MeterActivities], observations: list[Mete ra_number = activity.well.ra_number ose_tag = activity.well.osetag + meter_activity_photos = [ + MeterActivityPhotoDTO( + name=p.file_name, + url=build_activity_photo_url(activity.id, p.file_name), + ) + for p in (activity.photos or []) + ] + activity = ActivityDTO( activity_id=activity.id, - ose_request_id=activity.work_order.ose_request_id if activity.work_order else None, + ose_request_id=activity.work_order.ose_request_id + if activity.work_order + else None, activity_type=activity.activity_type.name, activity_start=activity.timestamp_start, activity_end=activity.timestamp_end, @@ -162,6 +192,7 @@ def reorganizeHistory(activities: list[MeterActivities], observations: list[Mete notes=notes_strings, parts_used=parts_used_strings, observations=activity_observations, + meter_activity_photos=meter_activity_photos, ) meter_activity_list.append(activity) @@ -200,6 +231,7 @@ def get_shared_history( joinedload(MeterActivities.meter), joinedload(MeterActivities.work_order), joinedload(MeterActivities.well), + selectinload(MeterActivities.photos), ) .filter( and_( @@ -240,6 +272,7 @@ def get_shared_history( return reorganizeHistory(activities_list, observations_list) + @ose_router.get( "/meter_maintenance_by_ose_request_id", response_model=list[DateHistoryDTO], @@ -260,7 +293,7 @@ def get_ose_maintenance_by_requestID( joinedload(MeterActivities.activity_type), joinedload(MeterActivities.parts_used), joinedload(MeterActivities.meter).joinedload(Meters.well), - joinedload(MeterActivities.work_order) + joinedload(MeterActivities.work_order), ) .join(workOrders) .where( @@ -279,9 +312,11 @@ def get_ose_maintenance_by_requestID( if not activities_list: return [] - + # Since observations do no include the OSE request ID, figure out what observations are associated with the activities using a date range - activities_start_date = min([activity.timestamp_start for activity in activities_list]) + activities_start_date = min( + [activity.timestamp_start for activity in activities_list] + ) activities_end_date = max([activity.timestamp_end for activity in activities_list]) # Get all observations in the date range @@ -310,6 +345,7 @@ def get_ose_maintenance_by_requestID( return reorganizeHistory(activities_list, observations_list) + @ose_router.get( "/meter_information", tags=["OSE"], @@ -319,16 +355,15 @@ def get_meter_information( serial_number: str, db: Session = Depends(get_db), ): - # Create the basic query query = select(Meters).options( - joinedload(Meters.meter_type), - joinedload(Meters.well).joinedload(Wells.location), - joinedload(Meters.status), - joinedload(Meters.meter_register).joinedload(meterRegisters.dial_units), - joinedload(Meters.meter_register).joinedload(meterRegisters.totalizer_units), - ) - + joinedload(Meters.meter_type), + joinedload(Meters.well).joinedload(Wells.location), + joinedload(Meters.status), + joinedload(Meters.meter_register).joinedload(meterRegisters.dial_units), + joinedload(Meters.meter_register).joinedload(meterRegisters.totalizer_units), + ) + query = query.filter(Meters.serial_number == serial_number) # Execute the query @@ -347,7 +382,9 @@ def get_meter_information( trss=meter.well.location.trss, longitude=meter.well.location.longitude, latitude=meter.well.location.latitude, - ) if meter.well else None, + ) + if meter.well + else None, notes=meter.notes, meter_type=meter_schemas.PublicMeter.MeterType( brand=meter.meter_type.brand, @@ -361,29 +398,28 @@ def get_meter_information( dial_units=meter.meter_register.dial_units.name, totalizer_units=meter.meter_register.totalizer_units.name, multiplier=meter.meter_register.multiplier, - ) if meter.meter_register else None, + ) + if meter.meter_register + else None, ) return output_meter + @ose_router.get( "/disapproval_response_by_request_id", tags=["OSE"], - response_model = DisapprovalStatus + response_model=DisapprovalStatus, ) def get_disapproval_response_by_request_id( - ose_request_id: int, - db: Session = Depends(get_db) + ose_request_id: int, db: Session = Depends(get_db) ): # Get the work order associated with the OSE request ID - work_order = ( - db.scalars( - select(workOrders) - .options(joinedload(workOrders.status)) - .where(workOrders.ose_request_id == ose_request_id) - ) - .first() - ) + work_order = db.scalars( + select(workOrders) + .options(joinedload(workOrders.status)) + .where(workOrders.ose_request_id == ose_request_id) + ).first() # Check if work order is a disapproval as determined by title "OSE Data Issue" isDisapproval = work_order.title[:14] == "OSE Data Issue" @@ -404,7 +440,7 @@ def get_disapproval_response_by_request_id( services=[], notes=[], parts_used=[], - observations=[] + observations=[], ) # Get any new activities that are associated with the disapproval work order @@ -415,7 +451,7 @@ def get_disapproval_response_by_request_id( joinedload(MeterActivities.activity_type), joinedload(MeterActivities.parts_used), joinedload(MeterActivities.meter).joinedload(Meters.well), - joinedload(MeterActivities.work_order) + joinedload(MeterActivities.work_order), ) .where(MeterActivities.work_order_id == work_order.id) ) @@ -441,7 +477,7 @@ def get_disapproval_response_by_request_id( na.services_performed, ) ) - + # Get observations for the meter in the time range of the activity observations = ( db.scalars( @@ -455,10 +491,12 @@ def get_disapproval_response_by_request_id( MeterObservations.timestamp >= na.timestamp_start, MeterObservations.timestamp <= na.timestamp_end, MeterObservations.meter_id == na.meter_id, - MeterObservations.ose_share == True + MeterObservations.ose_share == True, ) ) - ).unique().all() + ) + .unique() + .all() ) # Create the observation DTOs @@ -488,27 +526,25 @@ def get_disapproval_response_by_request_id( ) new_activitiesDTO.append(activity) - # Create the response model response = DisapprovalStatus( ose_request_id=work_order.ose_request_id, status=work_order.status.name, notes=work_order.notes, disapproval_activity=disapproval_activity, - new_activities=new_activitiesDTO + new_activities=new_activitiesDTO, ) return response + @ose_router.get( - "/get_DB_types", - tags=["OSE"], - response_model=meter_schemas.DBTypesForOSE + "/get_DB_types", tags=["OSE"], response_model=meter_schemas.DBTypesForOSE ) def get_DB_types(db: Session = Depends(get_db)): - ''' + """ Return DB types from lookup tables - ''' + """ # Load all the lookup tables activity_types = db.scalars(select(ActivityTypeLU)).all() observed_property_types = db.scalars(select(ObservedPropertyTypeLU)).all() @@ -516,37 +552,47 @@ def get_DB_types(db: Session = Depends(get_db)): note_types = db.scalars(select(NoteTypeLU)).all() meter_status_types = db.scalars(select(MeterStatusLU)).all() - # Convert to + # Convert to activity_types = list( map( - lambda x: meter_schemas.DBTypesForOSE.GeneralTypeInfo(name=x.name,description=x.description), - activity_types - ) + lambda x: meter_schemas.DBTypesForOSE.GeneralTypeInfo( + name=x.name, description=x.description + ), + activity_types, ) + ) observed_property_types = list( map( - lambda x: meter_schemas.DBTypesForOSE.GeneralTypeInfo(name=x.name,description=x.description), - observed_property_types - ) + lambda x: meter_schemas.DBTypesForOSE.GeneralTypeInfo( + name=x.name, description=x.description + ), + observed_property_types, ) + ) service_types = list( map( - lambda x: meter_schemas.DBTypesForOSE.GeneralTypeInfo(name=x.service_name,description=x.description), - service_types - ) + lambda x: meter_schemas.DBTypesForOSE.GeneralTypeInfo( + name=x.service_name, description=x.description + ), + service_types, ) + ) note_types = list( map( - lambda x: meter_schemas.DBTypesForOSE.GeneralTypeInfo(name=x.note,description=x.details), - note_types - ) + lambda x: meter_schemas.DBTypesForOSE.GeneralTypeInfo( + name=x.note, description=x.details + ), + note_types, ) + ) meter_status_types = list( map( - lambda x: meter_schemas.DBTypesForOSE.GeneralTypeInfo(name=x.status_name,description=x.description), - meter_status_types - ) + lambda x: meter_schemas.DBTypesForOSE.GeneralTypeInfo( + name=x.status_name, description=x.description + ), + meter_status_types, ) + ) # Create the response model response = meter_schemas.DBTypesForOSE( @@ -554,8 +600,7 @@ def get_DB_types(db: Session = Depends(get_db)): observed_property_types=observed_property_types, service_types=service_types, note_types=note_types, - meter_status_types=meter_status_types + meter_status_types=meter_status_types, ) return response - diff --git a/api/routes/activities.py b/api/routes/activities.py index 629a3867..0ff7d738 100644 --- a/api/routes/activities.py +++ b/api/routes/activities.py @@ -1,5 +1,6 @@ from fastapi import Depends, APIRouter, Query, File, UploadFile, Form from fastapi.exceptions import HTTPException +from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session, joinedload from sqlalchemy.exc import IntegrityError from sqlalchemy import select, text @@ -23,7 +24,7 @@ MeterStatusLU, Users, workOrders, - workOrderStatusLU + workOrderStatusLU, ) from api.session import get_db from api.security import get_current_user @@ -36,6 +37,7 @@ import os activity_router = APIRouter() +public_activity_router = APIRouter() BUCKET_NAME = os.getenv("GCP_BUCKET_NAME", "") PHOTO_PREFIX = os.getenv("GCP_PHOTO_PREFIX", "") @@ -43,6 +45,54 @@ MAX_PHOTOS_PER_REQUEST = 2 MAX_PHOTOS_PER_METER = 6 + +@public_activity_router.get("/activities/{activity_id}/photos/{photo_file_name}") +async def get_activity_photo( + activity_id: int, + photo_file_name: str, + db: Session = Depends(get_db), +): + photo = ( + db.query(MeterActivityPhotos) + .filter( + MeterActivityPhotos.meter_activity_id == activity_id, + MeterActivityPhotos.file_name == photo_file_name, + ) + .first() + ) + + if not photo: + raise HTTPException(status_code=404, detail="Photo not found for this activity") + + try: + client = storage.Client() + bucket = client.bucket(BUCKET_NAME) + blob = bucket.blob(photo.gcs_path) + + # Optional: ensure blob exists (avoids returning empty/500) + if not blob.exists(client=client): + raise HTTPException( + status_code=404, detail="Photo file missing from storage" + ) + + # Pull content type from GCS metadata (fallback if absent) + blob.reload(client=client) + content_type = blob.content_type or "application/octet-stream" + + # 3) Stream back to client + file_obj = blob.open("rb") # streaming file-like object + + # Inline display; if you want download behavior change to 'attachment' + headers = {"Content-Disposition": f'inline; filename="{photo.file_name}"'} + + return StreamingResponse(file_obj, media_type=content_type, headers=headers) + + except HTTPException: + raise + except Exception: + raise HTTPException(status_code=500, detail="Failed to retrieve photo") + + @activity_router.post( "/activities", response_model=meter_schemas.MeterActivity, @@ -64,7 +114,7 @@ async def post_activity( raise HTTPException( status_code=400, detail=f"Too many photos uploaded. " - f"Max {MAX_PHOTOS_PER_REQUEST} allowed per request, got {len(photos)}.", + f"Max {MAX_PHOTOS_PER_REQUEST} allowed per request, got {len(photos)}.", ) try: @@ -136,7 +186,7 @@ async def post_activity( activity_type_id=activity_form.activity_details.activity_type_id, location_id=activity_location, ose_share=activity_form.activity_details.share_ose, - water_users=activity_form.current_installation.water_users + water_users=activity_form.current_installation.water_users, ) # If a work order is associated with the activity, add it to the activity @@ -217,36 +267,37 @@ async def post_activity( meter_statuses = {status.status_name: status.id for status in meter_statuses} if update_meter_state: - if (activity_type.name == "Uninstall") or (activity_type.name == "Uninstall and Hold"): # This needs to be a slug - + if (activity_type.name == "Uninstall") or ( + activity_type.name == "Uninstall and Hold" + ): # This needs to be a slug activity_meter.location_id = hq_location.id activity_meter.well_id = None activity_meter.water_users = None if activity_type.name == "Uninstall and Hold": # Set status as On Hold - activity_meter.status_id = meter_statuses['On Hold'] + activity_meter.status_id = meter_statuses["On Hold"] else: # Set status as Uninstalled - activity_meter.status_id = meter_statuses['Warehouse'] + activity_meter.status_id = meter_statuses["Warehouse"] if activity_type.name == "Install": activity_meter.well_id = activity_well.id activity_meter.location_id = activity_location - activity_meter.status_id = meter_statuses['Installed'] + activity_meter.status_id = meter_statuses["Installed"] activity_meter.water_users = activity_form.current_installation.water_users if activity_type.name == "Scrap": activity_meter.well_id = None activity_meter.location_id = None - activity_meter.status_id = meter_statuses['Scrapped'] + activity_meter.status_id = meter_statuses["Scrapped"] activity_meter.water_users = None activity_meter.meter_owner = None if activity_type.name == "Sell": activity_meter.well_id = None activity_meter.location_id = None - activity_meter.status_id = meter_statuses['Sold'] + activity_meter.status_id = meter_statuses["Sold"] activity_meter.water_users = None activity_meter.meter_owner = activity_form.current_installation.meter_owner @@ -255,13 +306,16 @@ async def post_activity( # Make updates to the meter based on user's entry in the current installation section if activity_type.name != "Uninstall": - activity_meter.contact_name = activity_form.current_installation.contact_name - activity_meter.contact_phone = activity_form.current_installation.contact_phone + activity_meter.contact_name = ( + activity_form.current_installation.contact_name + ) + activity_meter.contact_phone = ( + activity_form.current_installation.contact_phone + ) activity_meter.notes = activity_form.current_installation.notes db.commit() - # ---- Handle photo file uploads ---- if photos: print(f"Received {len(photos)} photos") @@ -288,7 +342,7 @@ async def post_activity( photo = MeterActivityPhotos( meter_activity_id=meter_activity.id, - file_name=file.filename, + file_name=unique_name, gcs_path=blob_path, ) db.add(photo) @@ -313,25 +367,34 @@ async def post_activity( try: bucket.blob(old_photo.gcs_path).delete() except Exception as e: - print(f"Warning: failed to delete {old_photo.gcs_path} from GCS: {e}") + print( + f"Warning: failed to delete {old_photo.gcs_path} from GCS: {e}" + ) db.delete(old_photo) db.commit() return meter_activity + @activity_router.patch( - "/activities", - dependencies=[Depends(ScopedUser.Admin)], - tags=["Activities"], + "/activities", + dependencies=[Depends(ScopedUser.Admin)], + tags=["Activities"], ) -def patch_activity(patch_activity_form: meter_schemas.PatchActivity, db: Session = Depends(get_db)): - ''' +def patch_activity( + patch_activity_form: meter_schemas.PatchActivity, db: Session = Depends(get_db) +): + """ Patch an activity. All input times should be UTC - ''' + """ # Get the activity - activity = db.scalars(select(MeterActivities).where(MeterActivities.id == patch_activity_form.activity_id)).first() + activity = db.scalars( + select(MeterActivities).where( + MeterActivities.id == patch_activity_form.activity_id + ) + ).first() # Update the activity activity.timestamp_start = patch_activity_form.timestamp_start @@ -342,7 +405,9 @@ def patch_activity(patch_activity_form: meter_schemas.PatchActivity, db: Session # When updating location, if location_id is null assume the activity took place at the "Warehouse" if patch_activity_form.location_id is None: - hq_location = db.scalars(select(Locations).where(Locations.type_id == 1)).first() + hq_location = db.scalars( + select(Locations).where(Locations.type_id == 1) + ).first() activity.location_id = hq_location.id else: activity.location_id = patch_activity_form.location_id @@ -350,35 +415,56 @@ def patch_activity(patch_activity_form: meter_schemas.PatchActivity, db: Session # Update the notes # Easiest approach is to just delete existing and then re-add if there are any delete_sql = text('DELETE FROM "Notes" WHERE meter_activity_id = :activity_id') - db.execute(delete_sql, {'activity_id': patch_activity_form.activity_id}) + db.execute(delete_sql, {"activity_id": patch_activity_form.activity_id}) if patch_activity_form.note_ids: - insert_sql = text('INSERT INTO "Notes" (meter_activity_id, note_type_id) VALUES (:activity_id, :note_id)') + insert_sql = text( + 'INSERT INTO "Notes" (meter_activity_id, note_type_id) VALUES (:activity_id, :note_id)' + ) for note_id in patch_activity_form.note_ids: - db.execute(insert_sql, {'activity_id': patch_activity_form.activity_id, 'note_id': note_id}) + db.execute( + insert_sql, + {"activity_id": patch_activity_form.activity_id, "note_id": note_id}, + ) # Update the parts used delete_sql = text('DELETE FROM "PartsUsed" WHERE meter_activity_id = :activity_id') - db.execute(delete_sql, {'activity_id': patch_activity_form.activity_id}) + db.execute(delete_sql, {"activity_id": patch_activity_form.activity_id}) if patch_activity_form.part_ids: - insert_sql = text('INSERT INTO "PartsUsed" (meter_activity_id, part_id) VALUES (:activity_id, :part_id)') + insert_sql = text( + 'INSERT INTO "PartsUsed" (meter_activity_id, part_id) VALUES (:activity_id, :part_id)' + ) for part_id in patch_activity_form.part_ids: - db.execute(insert_sql, {'activity_id': patch_activity_form.activity_id, 'part_id': part_id}) + db.execute( + insert_sql, + {"activity_id": patch_activity_form.activity_id, "part_id": part_id}, + ) # Update the services performed - delete_sql = text('DELETE FROM "ServicesPerformed" WHERE meter_activity_id = :activity_id') - db.execute(delete_sql, {'activity_id': patch_activity_form.activity_id}) + delete_sql = text( + 'DELETE FROM "ServicesPerformed" WHERE meter_activity_id = :activity_id' + ) + db.execute(delete_sql, {"activity_id": patch_activity_form.activity_id}) if patch_activity_form.service_ids: - insert_sql = text('INSERT INTO "ServicesPerformed" (meter_activity_id, service_type_id) VALUES (:activity_id, :service_id)') + insert_sql = text( + 'INSERT INTO "ServicesPerformed" (meter_activity_id, service_type_id) VALUES (:activity_id, :service_id)' + ) for service_id in patch_activity_form.service_ids: - db.execute(insert_sql, {'activity_id': patch_activity_form.activity_id, 'service_id': service_id}) + db.execute( + insert_sql, + { + "activity_id": patch_activity_form.activity_id, + "service_id": service_id, + }, + ) # Commit the changes db.commit() - return {'status': 'success'} + return {"status": "success"} + @activity_router.delete( "/activities", @@ -386,17 +472,21 @@ def patch_activity(patch_activity_form: meter_schemas.PatchActivity, db: Session tags=["Activities"], ) def delete_activity(activity_id: int, db: Session = Depends(get_db)): - ''' + """ Deletes an activity. - ''' + """ # Get the activity - activity = db.scalars(select(MeterActivities).where(MeterActivities.id == activity_id)).first() + activity = db.scalars( + select(MeterActivities).where(MeterActivities.id == activity_id) + ).first() if not activity: raise HTTPException(status_code=404, detail="Activity not found.") photos = db.scalars( - select(MeterActivityPhotos).where(MeterActivityPhotos.meter_activity_id == activity_id) + select(MeterActivityPhotos).where( + MeterActivityPhotos.meter_activity_id == activity_id + ) ).all() storage_client = storage.Client() @@ -412,41 +502,50 @@ def delete_activity(activity_id: int, db: Session = Depends(get_db)): # Delete any notes associated with the activity sql = text('DELETE FROM "Notes" WHERE meter_activity_id = :activity_id') - db.execute(sql, {'activity_id': activity_id}) - + db.execute(sql, {"activity_id": activity_id}) + # Delete any services performed associated with the activity sql = text('DELETE FROM "ServicesPerformed" WHERE meter_activity_id = :activity_id') - db.execute(sql, {'activity_id': activity_id}) + db.execute(sql, {"activity_id": activity_id}) # Delete any parts used associated with the activity sql = text('DELETE FROM "PartsUsed" WHERE meter_activity_id = :activity_id') - db.execute(sql, {'activity_id': activity_id}) + db.execute(sql, {"activity_id": activity_id}) # Delete the activity db.delete(activity) db.commit() - return {'status': 'success'} + return {"status": "success"} @activity_router.patch( - "/observations", - dependencies=[Depends(ScopedUser.Admin)], - tags=["Activities"], + "/observations", + dependencies=[Depends(ScopedUser.Admin)], + tags=["Activities"], ) -def patch_observation(patch_observation_form: meter_schemas.PatchObservation, db: Session = Depends(get_db)): - ''' +def patch_observation( + patch_observation_form: meter_schemas.PatchObservation, + db: Session = Depends(get_db), +): + """ Patch an observation. All input times should be UTC - ''' + """ # Get the observation - observation = db.scalars(select(MeterObservations).where(MeterObservations.id == patch_observation_form.observation_id)).first() + observation = db.scalars( + select(MeterObservations).where( + MeterObservations.id == patch_observation_form.observation_id + ) + ).first() # Update the observation observation.timestamp = patch_observation_form.timestamp observation.value = patch_observation_form.value observation.notes = patch_observation_form.notes - observation.observed_property_type_id = patch_observation_form.observed_property_type_id + observation.observed_property_type_id = ( + patch_observation_form.observed_property_type_id + ) observation.unit_id = patch_observation_form.unit_id observation.meter_id = patch_observation_form.meter_id observation.submitting_user_id = patch_observation_form.submitting_user_id @@ -454,14 +553,17 @@ def patch_observation(patch_observation_form: meter_schemas.PatchObservation, db # When updating location, if location_id is null assume the observation took place at the "Warehouse" if patch_observation_form.location_id is None: - hq_location = db.scalars(select(Locations).where(Locations.type_id == 1)).first() + hq_location = db.scalars( + select(Locations).where(Locations.type_id == 1) + ).first() observation.location_id = hq_location.id else: observation.location_id = patch_observation_form.location_id db.commit() - return {'status': 'success'} + return {"status": "success"} + @activity_router.delete( "/observations", @@ -469,11 +571,13 @@ def patch_observation(patch_observation_form: meter_schemas.PatchObservation, db tags=["Activities"], ) def delete_observation(observation_id: int, db: Session = Depends(get_db)): - ''' + """ Deletes an observation. - ''' + """ # Get the observation - observation = db.scalars(select(MeterObservations).where(MeterObservations.id == observation_id)).first() + observation = db.scalars( + select(MeterObservations).where(MeterObservations.id == observation_id) + ).first() # Return error if the observation doesn't exist if not observation: @@ -483,7 +587,8 @@ def delete_observation(observation_id: int, db: Session = Depends(get_db)): db.delete(observation) db.commit() - return {'status': 'success'} + return {"status": "success"} + @activity_router.get( "/activity_types", @@ -566,14 +671,15 @@ def get_service_types(db: Session = Depends(get_db)): def get_note_types(db: Session = Depends(get_db)): return db.scalars(select(NoteTypeLU)).all() + @activity_router.get( "/work_orders", dependencies=[Depends(ScopedUser.Read)], tags=["Work Orders"], ) def get_work_orders( - filter_by_status: list[WorkOrderStatus] = Query(['Open']), - start_date: datetime = Query(datetime.strptime('2024-06-01', '%Y-%m-%d')), + filter_by_status: list[WorkOrderStatus] = Query(["Open"]), + start_date: datetime = Query(datetime.strptime("2024-06-01", "%Y-%m-%d")), db: Session = Depends(get_db), ): query_stmt = ( @@ -599,41 +705,48 @@ def get_work_orders( # group activities by work_order_id activities_by_wo = {} for act in relevant_activities: - activities_by_wo.setdefault(act.work_order_id, []).append({ - "id": act.id, - "timestamp_start": act.timestamp_start, - "timestamp_end": act.timestamp_end, - "description": act.description, - "submitting_user_id": act.submitting_user_id, - "meter_id": act.meter_id, - "activity_type_id": act.activity_type_id, - "location_id": act.location_id, - "location_name": act.location.name if act.location else None, - "ose_share": act.ose_share, - "water_users": act.water_users, - }) + activities_by_wo.setdefault(act.work_order_id, []).append( + { + "id": act.id, + "timestamp_start": act.timestamp_start, + "timestamp_end": act.timestamp_end, + "description": act.description, + "submitting_user_id": act.submitting_user_id, + "meter_id": act.meter_id, + "activity_type_id": act.activity_type_id, + "location_id": act.location_id, + "location_name": act.location.name if act.location else None, + "ose_share": act.ose_share, + "water_users": act.water_users, + } + ) # build output output = [] for wo in work_orders: - output.append({ - "work_order_id": wo.id, - "ose_request_id": wo.ose_request_id, - "date_created": wo.date_created, - "creator": wo.creator, - "meter_id": wo.meter.id, - "meter_serial": wo.meter.serial_number, - "title": wo.title, - "description": wo.description, - "status": wo.status.name, - "notes": wo.notes, - "assigned_user_id": wo.assigned_user_id, - "assigned_user": wo.assigned_user.username if wo.assigned_user else None, - "associated_activities": activities_by_wo.get(wo.id, []), - }) + output.append( + { + "work_order_id": wo.id, + "ose_request_id": wo.ose_request_id, + "date_created": wo.date_created, + "creator": wo.creator, + "meter_id": wo.meter.id, + "meter_serial": wo.meter.serial_number, + "title": wo.title, + "description": wo.description, + "status": wo.status.name, + "notes": wo.notes, + "assigned_user_id": wo.assigned_user_id, + "assigned_user": wo.assigned_user.username + if wo.assigned_user + else None, + "associated_activities": activities_by_wo.get(wo.id, []), + } + ) return output + # Create work order endpoint @activity_router.post( "/work_orders", @@ -641,20 +754,24 @@ def get_work_orders( response_model=meter_schemas.WorkOrder, tags=["Work Orders"], ) -def create_work_order(new_work_order: meter_schemas.CreateWorkOrder, db: Session = Depends(get_db)): - ''' +def create_work_order( + new_work_order: meter_schemas.CreateWorkOrder, db: Session = Depends(get_db) +): + """ Create a new work order dated to the current time. The only mandatory inputs are the date, meter ID, and the title of the work order. - ''' + """ # Get status ID Open - open_status = db.scalars(select(workOrderStatusLU).where(workOrderStatusLU.name == 'Open')).first() + open_status = db.scalars( + select(workOrderStatusLU).where(workOrderStatusLU.name == "Open") + ).first() # Create a new work order work_order = workOrders( - date_created = new_work_order.date_created, - meter_id = new_work_order.meter_id, - title = new_work_order.title, - status_id = open_status.id + date_created=new_work_order.date_created, + meter_id=new_work_order.meter_id, + title=new_work_order.title, + status_id=open_status.id, ) # Add optional fields if they exist @@ -676,81 +793,87 @@ def create_work_order(new_work_order: meter_schemas.CreateWorkOrder, db: Session db.commit() except IntegrityError as _e: raise HTTPException( - status_code=409, - detail="Title empty or already exists for this meter." + status_code=409, detail="Title empty or already exists for this meter." ) - + # Create a WorkOrder schema for the updated work order work_order_schema = meter_schemas.WorkOrder( - work_order_id = work_order.id, - date_created = work_order.date_created, - creator = work_order.creator, - meter_id = work_order.meter.id, - meter_serial = work_order.meter.serial_number, - title = work_order.title, - description = work_order.description, - status = work_order.status.name, - notes = work_order.notes, - assigned_user_id = work_order.assigned_user_id, - assigned_user= work_order.assigned_user.username if work_order.assigned_user else None + work_order_id=work_order.id, + date_created=work_order.date_created, + creator=work_order.creator, + meter_id=work_order.meter.id, + meter_serial=work_order.meter.serial_number, + title=work_order.title, + description=work_order.description, + status=work_order.status.name, + notes=work_order.notes, + assigned_user_id=work_order.assigned_user_id, + assigned_user=work_order.assigned_user.username + if work_order.assigned_user + else None, ) return work_order_schema - + # Patch work order endpoint @activity_router.patch( "/work_orders", response_model=meter_schemas.WorkOrder, tags=["Work Orders"], ) -def patch_work_order(patch_work_order_form: meter_schemas.PatchWorkOrder, user: Users = Depends(security.get_current_user), db: Session = Depends(get_db)): - ''' +def patch_work_order( + patch_work_order_form: meter_schemas.PatchWorkOrder, + user: Users = Depends(security.get_current_user), + db: Session = Depends(get_db), +): + """ Patch a work order. The input schema limits the fields that can be updated to the title, description, status, notes, and assigned user. This is to prevent confusion with other open work orders. - ''' + """ # Determine if update can be made by Tech comparison_work_order = meter_schemas.PatchWorkOrder( - work_order_id = patch_work_order_form.work_order_id, + work_order_id=patch_work_order_form.work_order_id, status=patch_work_order_form.status, - notes=patch_work_order_form.notes + notes=patch_work_order_form.notes, ) if comparison_work_order == patch_work_order_form: - update_scope = 'Technician' + update_scope = "Technician" else: - update_scope = 'Admin' + update_scope = "Admin" # Check if the user has the correct permissions to update the work order - if user.user_role.name not in [update_scope, 'Admin']: + if user.user_role.name not in [update_scope, "Admin"]: raise HTTPException( status_code=403, - detail="User does not have permission to update this work order." + detail="User does not have permission to update this work order.", ) # Get the work order work_order = db.scalars( select(workOrders) - .options(joinedload(workOrders.status), joinedload(workOrders.meter), joinedload(workOrders.assigned_user)) + .options( + joinedload(workOrders.status), + joinedload(workOrders.meter), + joinedload(workOrders.assigned_user), + ) .where(workOrders.id == patch_work_order_form.work_order_id) - ).first() - + ).first() + # Ensure the current user is assigned the work order if they are a technician - if user.user_role.name == 'Technician': + if user.user_role.name == "Technician": if work_order.assigned_user_id != user.id: raise HTTPException( status_code=403, - detail="User does not have permission to update this work order." + detail="User does not have permission to update this work order.", ) # An empty string for a title will silently fail due to the if statement below. Detect here and return an error to the user. if patch_work_order_form.title == "": - raise HTTPException( - status_code=422, - detail="Title cannot be empty." - ) - + raise HTTPException(status_code=422, detail="Title cannot be empty.") + # Update the work order if the field exists if patch_work_order_form.title: work_order.title = patch_work_order_form.title @@ -758,7 +881,11 @@ def patch_work_order(patch_work_order_form: meter_schemas.PatchWorkOrder, user: work_order.description = patch_work_order_form.description if patch_work_order_form.status: # Get the status ID of the new status name - new_status = db.scalars(select(workOrderStatusLU).where(workOrderStatusLU.name == patch_work_order_form.status)).first() + new_status = db.scalars( + select(workOrderStatusLU).where( + workOrderStatusLU.name == patch_work_order_form.status + ) + ).first() work_order.status_id = new_status.id if patch_work_order_form.notes: work_order.notes = patch_work_order_form.notes @@ -773,37 +900,47 @@ def patch_work_order(patch_work_order_form: meter_schemas.PatchWorkOrder, user: db.commit() except IntegrityError as _e: raise HTTPException( - status_code=409, - detail="Title already exists for this meter." + status_code=409, detail="Title already exists for this meter." ) - + # Get the updated work order (needed by the frontend) work_order = db.scalars( select(workOrders) - .options(joinedload(workOrders.status), joinedload(workOrders.meter), joinedload(workOrders.assigned_user)) - .join(workOrderStatusLU).where(workOrders.id == patch_work_order_form.work_order_id)).first() - + .options( + joinedload(workOrders.status), + joinedload(workOrders.meter), + joinedload(workOrders.assigned_user), + ) + .join(workOrderStatusLU) + .where(workOrders.id == patch_work_order_form.work_order_id) + ).first() + # I was unable to get associated_activities to work with joinedload, so I'm doing it manually here - associated_activities = db.scalars(select(MeterActivities).where(MeterActivities.work_order_id == work_order.id)).all() - + associated_activities = db.scalars( + select(MeterActivities).where(MeterActivities.work_order_id == work_order.id) + ).all() + # Create a WorkOrder schema for the updated work order work_order_schema = meter_schemas.WorkOrder( - work_order_id = work_order.id, - date_created = work_order.date_created, - creator = work_order.creator, - meter_id = work_order.meter.id, - meter_serial = work_order.meter.serial_number, - title = work_order.title, - description = work_order.description, - status = work_order.status.name, - notes = work_order.notes, - assigned_user_id = work_order.assigned_user_id, - assigned_user= work_order.assigned_user.username if work_order.assigned_user else None, - associated_activities=list(associated_activities) + work_order_id=work_order.id, + date_created=work_order.date_created, + creator=work_order.creator, + meter_id=work_order.meter.id, + meter_serial=work_order.meter.serial_number, + title=work_order.title, + description=work_order.description, + status=work_order.status.name, + notes=work_order.notes, + assigned_user_id=work_order.assigned_user_id, + assigned_user=work_order.assigned_user.username + if work_order.assigned_user + else None, + associated_activities=list(associated_activities), ) return work_order_schema + # Delete work order endpoint @activity_router.delete( "/work_orders", @@ -811,11 +948,13 @@ def patch_work_order(patch_work_order_form: meter_schemas.PatchWorkOrder, user: tags=["Work Orders"], ) def delete_work_order(work_order_id: int, db: Session = Depends(get_db)): - ''' + """ Deletes a work order. - ''' + """ # Get the work order - work_order = db.scalars(select(workOrders).where(workOrders.id == work_order_id)).first() + work_order = db.scalars( + select(workOrders).where(workOrders.id == work_order_id) + ).first() # Return error if the work order doesn't exist if not work_order: @@ -825,4 +964,4 @@ def delete_work_order(work_order_id: int, db: Session = Depends(get_db)): db.delete(work_order) db.commit() - return {'status': 'success'} + return {"status": "success"} diff --git a/api/routes/maintenance.py b/api/routes/maintenance.py index b4b9efe1..2a4b3449 100644 --- a/api/routes/maintenance.py +++ b/api/routes/maintenance.py @@ -8,7 +8,6 @@ from io import BytesIO from collections import defaultdict from matplotlib.pyplot import figure, close -from base64 import b64encode from api.models.main_models import ( Users, Meters, @@ -22,6 +21,7 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape import matplotlib + matplotlib.use("Agg") # Force non-GUI backend @@ -29,7 +29,7 @@ templates = Environment( loader=FileSystemLoader(TEMPLATES_DIR), - autoescape=select_autoescape(["html", "xml"]) + autoescape=select_autoescape(["html", "xml"]), ) maintenance_router = APIRouter() @@ -63,7 +63,7 @@ class MaintenanceSummaryResponse(BaseModel): ) def get_maintenance_summary( from_date: date = Query(..., description="Start date YYYY-MM-DD"), - to_date: date = Query(..., description="End date YYYY-MM-DD"), + to_date: date = Query(..., description="End date YYYY-MM-DD"), trss: str = Query(...), technicians: List[int] = Query(...), db: Session = Depends(get_db), @@ -85,14 +85,14 @@ def get_maintenance_summary( # normalize input (strip spaces) trss_str = trss.strip() location_ids = [ - loc_id for (loc_id,) in db.query(Locations.id) + loc_id + for (loc_id,) in db.query(Locations.id) .filter(Locations.trss.like(f"{trss_str}%")) .all() ] if location_ids: - meter_subq = ( - db.query(Meters.id) - .filter(Meters.location_id.in_(location_ids)) + meter_subq = db.query(Meters.id).filter( + Meters.location_id.in_(location_ids) ) matching_meter_ids = [m_id for (m_id,) in meter_subq.all()] except Exception: @@ -104,23 +104,18 @@ def get_maintenance_summary( Users.full_name.label("technician"), Meters.serial_number.label("meter"), ActivityTypeLU.name.label("activity_type"), - Locations.trss.label("trss") + Locations.trss.label("trss"), ) .join(Users, Users.id == MeterActivities.submitting_user_id) .join(Meters, Meters.id == MeterActivities.meter_id) - .join( - ActivityTypeLU, - ActivityTypeLU.id == MeterActivities.activity_type_id - ) + .join(ActivityTypeLU, ActivityTypeLU.id == MeterActivities.activity_type_id) .join(Locations, Locations.id == Meters.location_id, isouter=True) .filter(MeterActivities.timestamp_start >= start_dt) .filter(MeterActivities.timestamp_start <= end_dt) ) if filter_techs: - query = query.filter( - MeterActivities.submitting_user_id.in_(technicians) - ) + query = query.filter(MeterActivities.submitting_user_id.in_(technicians)) if matching_meter_ids is not None: if not matching_meter_ids: @@ -137,33 +132,42 @@ def get_maintenance_summary( pms_by_meter = defaultdict(int) grouped_rows = defaultdict(lambda: {"number_of_repairs": 0, "number_of_pms": 0}) + total_repairs = 0 + total_pms = 0 + for row in base_query: key = (row.date_time, row.technician, row.meter, row.trss) if row.activity_type == "Repair": repairs_by_meter[row.meter] += 1 grouped_rows[key]["number_of_repairs"] += 1 + total_repairs += 1 elif row.activity_type == "Preventative Maintenance": pms_by_meter[row.meter] += 1 grouped_rows[key]["number_of_pms"] += 1 + total_pms += 1 repairs_result = [{"meter": m, "count": c} for m, c in repairs_by_meter.items()] - pms_result = [{"meter": m, "count": c} for m, c in pms_by_meter.items()] + pms_result = [{"meter": m, "count": c} for m, c in pms_by_meter.items()] table_rows = [] for (date_time, technician, meter, trss_val), counts in grouped_rows.items(): - table_rows.append({ - "date_time": date_time, - "technician": technician, - "meter": meter, - "trss": trss_val or "", - "number_of_repairs": counts["number_of_repairs"], - "number_of_pms": counts["number_of_pms"], - }) + table_rows.append( + { + "date_time": date_time, + "technician": technician, + "meter": meter, + "trss": trss_val or "", + "number_of_repairs": counts["number_of_repairs"], + "number_of_pms": counts["number_of_pms"], + } + ) return { "repairs_by_meter": repairs_result, "pms_by_meter": pms_result, "table_rows": table_rows, + "total_repairs": total_repairs, + "total_pms": total_pms, } @@ -174,7 +178,7 @@ def get_maintenance_summary( ) def download_maintenance_summary_pdf( from_date: date = Query(..., description="Start date YYYY-MM-DD"), - to_date: date = Query(..., description="End date YYYY-MM-DD"), + to_date: date = Query(..., description="End date YYYY-MM-DD"), trss: str = Query(...), technicians: List[int] = Query(...), db: Session = Depends(get_db), @@ -192,39 +196,15 @@ def download_maintenance_summary_pdf( db=db, ) - # Make pie charts as base64 PNGs - def make_pie_chart(data: dict, title: str): - if not data: - return "" - fig = figure(figsize=(5, 5)) - ax = fig.add_subplot(111) - ax.pie( - data.values(), - labels=data.keys(), - autopct="%1.1f%%", - startangle=140, - ) - ax.set_title(title) - buf = BytesIO() - fig.savefig(buf, format="png", bbox_inches="tight") - close(fig) - return b64encode(buf.getvalue()).decode("utf-8") - - repair_chart_b64 = make_pie_chart( - {r["meter"]: r["count"] for r in summary["repairs_by_meter"]}, - "Repairs by Meter" - ) - pm_chart_b64 = make_pie_chart( - {p["meter"]: p["count"] for p in summary["pms_by_meter"]}, - "Preventative Maintenances by Meter" - ) + total_repairs = summary["total_repairs"] + total_pms = summary["total_pms"] template = templates.get_template("maintenance_summary.html") html = template.render( from_date=from_date, to_date=to_date, - repair_chart=repair_chart_b64, - pm_chart=pm_chart_b64, + total_repairs=total_repairs, + total_pms=total_pms, table_rows=summary["table_rows"], ) diff --git a/api/routes/well_measurements.py b/api/routes/well_measurements.py index fe7e2fb4..4513fd1e 100644 --- a/api/routes/well_measurements.py +++ b/api/routes/well_measurements.py @@ -14,14 +14,21 @@ from base64 import b64encode from api.schemas import well_schemas -from api.models.main_models import WellMeasurements, ObservedPropertyTypeLU, Units, Wells +from api.models.main_models import ( + WellMeasurements, + ObservedPropertyTypeLU, + Units, + Wells, +) from api.session import get_db from api.enums import ScopedUser from google.cloud import storage from pathlib import Path from jinja2 import Environment, FileSystemLoader, select_autoescape +from zoneinfo import ZoneInfo +import zlib import json import os import matplotlib @@ -34,7 +41,7 @@ templates = Environment( loader=FileSystemLoader(TEMPLATES_DIR), - autoescape=select_autoescape(["html", "xml"]) + autoescape=select_autoescape(["html", "xml"]), ) authenticated_well_measurement_router = APIRouter() @@ -85,33 +92,97 @@ def read_woodpecker_waterlevels( if well_id != SP_JOHNSON_WELL_ID: raise HTTPException(status_code=400, detail="Invalid well ID") + DEPTH_TO_WATER_SENSOR_NAME = "Depth to Water" + + results: List[well_schemas.WellMeasurementDTO] = [] + seen_timestamps: set[str] = set() + client = storage.Client() bucket = client.bucket(WOODPECKER_BUCKET_NAME) - blobs = bucket.list_blobs() - - results = [] - for blob in blobs: - if blob.name.endswith(".json"): - content = blob.download_as_text() - data = json.loads(content) - - measurement = well_schemas.WellMeasurementDTO( - id=data["id"], - timestamp=datetime.fromisoformat(data["timestamp"]), - value=data.get("value"), - submitting_user=well_schemas.WellMeasurementDTO.UserDTO( - full_name=data["submitting_user"]["full_name"] - ), - well=well_schemas.WellMeasurementDTO.WellDTO( - ra_number=data["well"]["ra_number"] - ), + for blob in bucket.list_blobs(): + if not blob.name.endswith(".json"): + continue + + content = blob.download_as_text() + payload = json.loads(content) + + device_attributes = payload.get("deviceAttributes") or {} + tz_name = device_attributes.get("timeZone") or "UTC" + ra_number = device_attributes.get("wellId") or "" # e.g. "RA-3502" + + sensor_data = payload.get("sensorData") or [] + depth_sensor = next( + ( + s + for s in sensor_data + if (s.get("sensorName") or "").strip() == DEPTH_TO_WATER_SENSOR_NAME + ), + None, + ) + if not depth_sensor: + # No "Depth to Water" in this file; skip + continue + + measurements = depth_sensor.get("measurements") or [] + for m in measurements: + raw_ts = m.get("timestamp") + if not raw_ts: + continue + + ts = _parse_woodpecker_timestamp(raw_ts, tz_name) + + # Deduplicate by exact instant string (timezone-aware isoformat if tz parsed) + ts_key = ts.isoformat() + if ts_key in seen_timestamps: + continue + seen_timestamps.add(ts_key) + + raw_value = m.get("data") + value = abs(raw_value) if raw_value is not None else None + + measurement_id = _make_measurement_id(well_id, ts, value) + + results.append( + well_schemas.WellMeasurementDTO( + id=measurement_id, + timestamp=ts, + value=value, + submitting_user=well_schemas.WellMeasurementDTO.UserDTO( + full_name="Woodpeckers" + ), + well=well_schemas.WellMeasurementDTO.WellDTO(ra_number=ra_number), + ) ) - results.append(measurement) + # Sort combined results across all files + results.sort(key=lambda r: r.timestamp) return results +def _parse_woodpecker_timestamp(ts: str, tz_name: str) -> datetime: + """ + Payload timestamp format: "DD/MM/YYYY HH:mm:ss" + Example: "29/12/2025 00:20:40" + """ + dt_naive = datetime.strptime(ts, "%d/%m/%Y %H:%M:%S") + try: + tz = ZoneInfo(tz_name) + except Exception: + # Fallback: keep naive if timezone is missing/invalid + return dt_naive + return dt_naive.replace(tzinfo=tz) + + +def _make_measurement_id(well_id: int, ts: datetime, value: Optional[float]) -> int: + """ + Since the incoming format doesn't provide an integer measurement id, + generate a deterministic-ish int id from well_id + timestamp + value. + """ + key = f"{well_id}|{ts.isoformat()}|{value if value is not None else 'null'}" + return zlib.crc32(key.encode("utf-8")) + + @public_well_measurement_router.get( "/waterlevels", response_model=List[well_schemas.WellMeasurementDTO], @@ -139,6 +210,7 @@ def read_waterlevels( def group_and_average(measurements, group_by_label: str): from collections import defaultdict + grouped = defaultdict(list) for m in measurements: key = m.timestamp.strftime( @@ -221,6 +293,7 @@ def add_year_average(year: int, label: str): if from_date and to_date: start = datetime(year, from_date.month, 1) import calendar + last_day = calendar.monthrange(year, to_date.month)[1] end = datetime(year, to_date.month, last_day, 23, 59, 59) else: @@ -246,7 +319,9 @@ def add_year_average(year: int, label: str): try: year_int = int(comparisonYear) except ValueError: - raise HTTPException(status_code=400, detail="comparisonYear must be a 4-digit year") + raise HTTPException( + status_code=400, detail="comparisonYear must be a 4-digit year" + ) current_year = datetime.now().year if year_int < 1900 or year_int > current_year: @@ -307,6 +382,7 @@ def download_waterlevels_pdf( def shift_year_safe(dt, new_year: int): """Shift dt to new_year, handling Feb 29 / month-end safely.""" import calendar + try: return dt.replace(year=new_year) except ValueError: @@ -323,11 +399,13 @@ def shift_year_safe(dt, new_year: int): val = m.value ra = m.well["ra_number"] if isinstance(m.well, dict) else m.well.ra_number - rows.append({ - "timestamp": ts.strftime("%Y-%m-%d %H:%M"), - "depth_to_water": val, - "well_ra_number": ra, - }) + rows.append( + { + "timestamp": ts.strftime("%Y-%m-%d %H:%M"), + "depth_to_water": val, + "well_ra_number": ra, + } + ) chart_ts = ts if from_year: @@ -348,12 +426,21 @@ def make_line_chart(data: dict, title: str): sorted_m = sorted(measurements, key=lambda x: x[0]) timestamps = [ts for ts, _ in sorted_m] values = [val for _, val in sorted_m] - ax.plot(timestamps, values, label=ra_label, marker='o') + ax.plot(timestamps, values, label=ra_label, marker="o") ax.set_title(title) ax.set_xlabel("Time") ax.set_ylabel("Depth to Water") ax.invert_yaxis() - ax.legend() + + # Reserve Space on the top right & move legend outside the plot area to that reserved area + fig.subplots_adjust(right=0.78) + ax.legend( + loc="center left", + bbox_to_anchor=(1.02, 0.5), + borderaxespad=0.0, + frameon=True, + ) + fig.autofmt_xdate() buf = BytesIO() fig.savefig(buf, format="png", bbox_inches="tight") @@ -389,9 +476,7 @@ def make_line_chart(data: dict, title: str): return StreamingResponse( pdf_io, media_type="application/pdf", - headers={ - "Content-Disposition": "attachment; filename=waterlevels_report.pdf" - }, + headers={"Content-Disposition": "attachment; filename=waterlevels_report.pdf"}, ) @@ -401,11 +486,15 @@ def make_line_chart(data: dict, title: str): response_model=well_schemas.WellMeasurement, tags=["WaterLevels"], ) -def patch_waterlevel(waterlevel_patch: well_schemas.PatchWaterLevel, db: Session = Depends(get_db)): +def patch_waterlevel( + waterlevel_patch: well_schemas.PatchWaterLevel, db: Session = Depends(get_db) +): # Find the measurement - well_measurement = ( - db.scalars(select(WellMeasurements).where(WellMeasurements.id == waterlevel_patch.levelmeasurement_id)).first() - ) + well_measurement = db.scalars( + select(WellMeasurements).where( + WellMeasurements.id == waterlevel_patch.levelmeasurement_id + ) + ).first() # Update the fields, all are mandatory well_measurement.submitting_user_id = waterlevel_patch.submitting_user_id @@ -416,6 +505,7 @@ def patch_waterlevel(waterlevel_patch: well_schemas.PatchWaterLevel, db: Session return well_measurement + @authenticated_well_measurement_router.delete( "/waterlevels", dependencies=[Depends(ScopedUser.Admin)], @@ -423,9 +513,9 @@ def patch_waterlevel(waterlevel_patch: well_schemas.PatchWaterLevel, db: Session ) def delete_waterlevel(waterlevel_id: int, db: Session = Depends(get_db)): # Find the measurement - well_measurement = ( - db.scalars(select(WellMeasurements).where(WellMeasurements.id == waterlevel_id)).first() - ) + well_measurement = db.scalars( + select(WellMeasurements).where(WellMeasurements.id == waterlevel_id) + ).first() db.delete(well_measurement) db.commit() diff --git a/api/routes/wells.py b/api/routes/wells.py index 95130445..91dfe8c3 100644 --- a/api/routes/wells.py +++ b/api/routes/wells.py @@ -28,6 +28,7 @@ def get_use_types( ): return db.scalars(select(WellUseLU)).all() + # Get water sources @authenticated_well_router.get( "/water_sources", @@ -40,6 +41,7 @@ def get_water_sources( ): return db.scalars(select(WaterSources)).all() + # Get well status types @authenticated_well_router.get( "/well_status_types", @@ -86,7 +88,12 @@ def sort_by_field_to_schema_field(name: WellSortByField): query_statement = ( select(Wells) - .options(joinedload(Wells.location), joinedload(Wells.use_type), joinedload(Wells.meters), joinedload(Wells.well_status)) + .options( + joinedload(Wells.location), + joinedload(Wells.use_type), + joinedload(Wells.meters), + joinedload(Wells.well_status), + ) .join(Locations, isouter=True) .join(WellUseLU, isouter=True) ) @@ -110,7 +117,6 @@ def sort_by_field_to_schema_field(name: WellSortByField): Wells.chloride_group_id == int(chloride_group_id) ) - if sort_by: schema_field_name = sort_by_field_to_schema_field(sort_by) @@ -130,9 +136,7 @@ def sort_by_field_to_schema_field(name: WellSortByField): response_model=well_schemas.WellResponse, tags=["Wells"], ) -def update_well( - updated_well: well_schemas.WellUpdate, db: Session = Depends(get_db) -): +def update_well(updated_well: well_schemas.WellUpdate, db: Session = Depends(get_db)): # If present, update location and remove from model if updated_well.location: _patch(db, Locations, updated_well.location.id, updated_well.location) @@ -141,29 +145,26 @@ def update_well( if updated_well.use_type: updated_well.use_type_id = updated_well.use_type.id - # If water_source is present, update the id and remove from model if updated_well.water_source: updated_well.water_source_id = updated_well.water_source.id - # If well_status is present, update the id and remove from model if updated_well.well_status: updated_well.well_status_id = updated_well.well_status.id - # Update well well_to_patch = _get(db, Wells, updated_well.id) for k, v in updated_well.model_dump(exclude_unset=True).items(): # Skip updating relationships - if k in ['location', 'use_type', 'water_source', 'well_status']: + if k in ["location", "use_type", "water_source", "well_status"]: continue try: setattr(well_to_patch, k, v) except AttributeError as e: - print(f'Attribute: {k}') + print(f"Attribute: {k}") print(e) continue @@ -177,7 +178,11 @@ def update_well( updated_well_model = db.scalars( select(Wells) .where(Wells.id == updated_well.id) - .options(joinedload(Wells.use_type), joinedload(Wells.location), joinedload(Wells.meters)) + .options( + joinedload(Wells.use_type), + joinedload(Wells.location), + joinedload(Wells.meters), + ) ).first() # Return qualified well model @@ -192,7 +197,7 @@ def update_well( def create_well(new_well: well_schemas.SubmitWellCreate, db: Session = Depends(get_db)): # First, commit the new location that was added with the new well new_location_model = Locations( - #name=new_well.location.name, + name=new_well.location.name, type_id=2, trss=new_well.location.trss, latitude=new_well.location.latitude, @@ -206,7 +211,7 @@ def create_well(new_well: well_schemas.SubmitWellCreate, db: Session = Depends(g # Then, commit the well using the location we just created try: new_well_model = Wells( - #name=new_well.name, + name=new_well.name, use_type_id=new_well.use_type.id, location_id=new_location_model.id, ra_number=new_well.ra_number, @@ -256,9 +261,7 @@ def get_wells_locations( joinedload(Wells.location), joinedload(Wells.use_type), ) - .where( - Wells.location_id.isnot(None) - ) + .where(Wells.location_id.isnot(None)) ) if search_string: @@ -267,7 +270,7 @@ def get_wells_locations( Wells.name.ilike(f"%{search_string}%"), Wells.ra_number.ilike(f"%{search_string}%"), Wells.owners.ilike(f"%{search_string}%"), - Wells.osetag.ilike(f"%{search_string}%") + Wells.osetag.ilike(f"%{search_string}%"), ) ) @@ -292,18 +295,25 @@ def get_well(well_id: int, db: Session = Depends(get_db)): .filter(Wells.id == well_id) ).first() + @authenticated_well_router.post( "/merge_wells", dependencies=[Depends(ScopedUser.Admin)], tags=["Wells"], ) def merge_well(well: well_schemas.SubmitWellMerge, db: Session = Depends(get_db)): - ''' + """ Transfers the history of merge well to target well then deletes the merge well - ''' - merge_well = db.scalars(select(Wells).where(Wells.ra_number == well.merge_well)).first() - target_well = db.scalars(select(Wells).where(Wells.ra_number == well.target_well)).first() - merge_location = db.scalars(select(Locations).where(Locations.id == merge_well.location_id)).first() + """ + merge_well = db.scalars( + select(Wells).where(Wells.ra_number == well.merge_well) + ).first() + target_well = db.scalars( + select(Wells).where(Wells.ra_number == well.target_well) + ).first() + merge_location = db.scalars( + select(Locations).where(Locations.id == merge_well.location_id) + ).first() # Transfer history of merge well to target well # Change well_id and location_id of Meters table to target well_id and location_id @@ -313,33 +323,42 @@ def merge_well(well: well_schemas.SubmitWellMerge, db: Session = Depends(get_db) WHERE well_id = :merge_well_id """) - db.execute(meters_sql, { - 'target_well_id': target_well.id, - 'target_location_id': target_well.location_id, - 'merge_well_id': merge_well.id - }) + db.execute( + meters_sql, + { + "target_well_id": target_well.id, + "target_location_id": target_well.location_id, + "merge_well_id": merge_well.id, + }, + ) # Update meter activities table to target well_id and location_id meter_activities_sql = text(""" UPDATE "MeterActivities" SET location_id = :target_location_id WHERE location_id = :merge_location_id """) - db.execute(meter_activities_sql, { - 'target_well_id': target_well.id, - 'target_location_id': target_well.location_id, - 'merge_location_id': merge_well.location_id - }) + db.execute( + meter_activities_sql, + { + "target_well_id": target_well.id, + "target_location_id": target_well.location_id, + "merge_location_id": merge_well.location_id, + }, + ) # Update meter observations table to target well_id and location_id meter_observations_sql = text(""" UPDATE "MeterObservations" SET location_id = :target_location_id WHERE location_id = :merge_location_id """) - db.execute(meter_observations_sql, { - 'target_well_id': target_well.id, - 'target_location_id': target_well.location_id, - 'merge_location_id': merge_well.location_id - }) + db.execute( + meter_observations_sql, + { + "target_well_id": target_well.id, + "target_location_id": target_well.location_id, + "merge_location_id": merge_well.location_id, + }, + ) # Delete merge well and location db.delete(merge_well) @@ -348,5 +367,3 @@ def merge_well(well: well_schemas.SubmitWellMerge, db: Session = Depends(get_db) db.commit() return True - - diff --git a/api/templates/maintenance_summary.html b/api/templates/maintenance_summary.html index d732f956..2f55203d 100644 --- a/api/templates/maintenance_summary.html +++ b/api/templates/maintenance_summary.html @@ -1,84 +1,97 @@ + + + - .chart { - margin-top: 2em; - text-align: center; - } - - + +

Maintenance Summary

+

+ From: {{ from_date }}    + To: {{ to_date }} +

- -

Maintenance Summary

-

- From: {{ from_date }}    - To: {{ to_date }} -

+

Totals

+
+
+
Total Repairs
+
{{ total_repairs }}
+
- {% if repair_chart %} -
-

Repairs by Meter

- -
- {% endif %} +
+
+ Total Preventative Maintenances +
+
{{ total_pms }}
+
+
- {% if pm_chart %} -
-

Preventative Maintenance by Meter

- -
- {% endif %} - -

Detailed Activity Table

- - - - - - - - - - - - {% for row in table_rows %} - - - - - - - - {% endfor %} - -
Date / TimeTechnicianMeterNumber of RepairsNumber of Preventative Maintenances
{{ row.date_time }}{{ row.technician }}{{ row.meter }}{{ row.number_of_repairs }}{{ row.number_of_pms }}
- - - \ No newline at end of file +

Detailed Activity Table

+ + + + + + + + + + + + {% for row in table_rows %} + + + + + + + + {% endfor %} + +
Date / TimeTechnicianMeterNumber of RepairsNumber of Preventative Maintenances
{{ row.date_time }}{{ row.technician }}{{ row.meter }}{{ row.number_of_repairs }}{{ row.number_of_pms }}
+ + diff --git a/docker-compose.development.yml b/docker-compose.development.yml index 91d54123..38fa16aa 100644 --- a/docker-compose.development.yml +++ b/docker-compose.development.yml @@ -31,6 +31,7 @@ services: dockerfile: ./Dockerfile working_dir: /app environment: + - API_BASE_URL=https://pvacd-dev.newmexicowaterdata.org/api/v1 - GCP_BUCKET_NAME=pvacd - GCP_WOODPECKER_BUCKET_NAME=pvacd-woodpecker - GCP_BACKUP_PREFIX=pre-prod-db-backups diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 0c4f5f90..e189f3c1 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -31,6 +31,7 @@ services: dockerfile: ./Dockerfile working_dir: /app environment: + - API_BASE_URL=https://pvacd.newmexicowaterdata.org/api/v1 - GCP_BUCKET_NAME=pvacd - GCP_WOODPECKER_BUCKET_NAME=pvacd-woodpecker - GCP_BACKUP_PREFIX=prod-db-backups diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ec88b22e..3f7d7aea 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -34,7 +34,8 @@ "react-number-format": "^5.3.1", "react-plotly.js": "^2.6.0", "react-query": "^3.39.3", - "react-router-dom": "^6.4.2", + "react-router": "^6.30.3", + "react-router-dom": "^6.30.3", "serve": "^14.0.1", "use-debounce": "^9.0.4", "yup": "^1.2.0" @@ -2406,9 +2407,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", - "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -8067,12 +8068,12 @@ } }, "node_modules/react-router": { - "version": "6.30.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.0.tgz", - "integrity": "sha512-D3X8FyH9nBcTSHGdEKurK7r8OYE1kKFn3d/CF+CoxbSHkxU7o37+Uh7eAHRXr6k2tSExXYO++07PeXJtA/dEhQ==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.0" + "@remix-run/router": "1.23.2" }, "engines": { "node": ">=14.0.0" @@ -8082,13 +8083,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.30.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.0.tgz", - "integrity": "sha512-x30B78HV5tFk8ex0ITwzC9TTZMua4jGyA9IUlH1JLQYQTFyxr/ZxwOJq7evg1JX1qGVUcvhsmQSKdPncQrjTgA==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.0", - "react-router": "6.30.0" + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" }, "engines": { "node": ">=14.0.0" diff --git a/frontend/package.json b/frontend/package.json index 3a210167..07e2ae50 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,7 +36,8 @@ "react-number-format": "^5.3.1", "react-plotly.js": "^2.6.0", "react-query": "^3.39.3", - "react-router-dom": "^6.4.2", + "react-router": "^6.30.3", + "react-router-dom": "^6.30.3", "serve": "^14.0.1", "use-debounce": "^9.0.4", "yup": "^1.2.0" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1039798a..494b6e33 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,21 +1,14 @@ import { useEffect, useState } from "react"; import { AuthProvider } from "react-auth-kit"; -import { - Route, - BrowserRouter as Router, - Routes, -} from "react-router-dom"; +import { Route, BrowserRouter as Router, Routes } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "react-query"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { LocalizationProvider } from "@mui/x-date-pickers"; import { SnackbarProvider, enqueueSnackbar } from "notistack"; -import { - Home, - Login, - Settings, -} from './views' +import { Home, Login, Settings } from "./views"; import { MonitoringWellsView } from "./views/MonitoringWells/MonitoringWellsView"; import { ActivitiesView } from "./views/Activities/ActivitiesView"; +import { ActivityPhotoView } from "./views/Activities/ActivityPhotoView"; import { MetersView } from "./views/Meters/MetersView"; import { PartsView } from "./views/Parts/PartsView"; import { UserManagementView } from "./views/UserManagement/UserManagementView"; @@ -94,7 +87,10 @@ export const App = () => { + @@ -104,7 +100,10 @@ export const App = () => { + @@ -114,17 +113,36 @@ export const App = () => { + } /> + + + + + + } + /> + @@ -134,7 +152,10 @@ export const App = () => { + @@ -144,7 +165,10 @@ export const App = () => { + @@ -154,7 +178,10 @@ export const App = () => { + @@ -164,7 +191,10 @@ export const App = () => { + @@ -174,7 +204,10 @@ export const App = () => { + @@ -184,7 +217,10 @@ export const App = () => { + @@ -194,7 +230,10 @@ export const App = () => { + @@ -204,7 +243,10 @@ export const App = () => { + @@ -214,7 +256,10 @@ export const App = () => { + @@ -224,7 +269,10 @@ export const App = () => { + diff --git a/frontend/src/interfaces.d.ts b/frontend/src/interfaces.d.ts index 482b4d2a..50ca0400 100644 --- a/frontend/src/interfaces.d.ts +++ b/frontend/src/interfaces.d.ts @@ -62,8 +62,8 @@ export interface ActivityFormControl { observations: Array<{ time: Dayjs reading: '' | number - property_type: Partial | null - unit: Partial | null + property_type_id: number | null + unit_id: number | null }>, maintenance_repair?: { service_type_ids: number[] | null, diff --git a/frontend/src/interfaces/DeviceAttributes.ts b/frontend/src/interfaces/DeviceAttributes.ts new file mode 100644 index 00000000..d3c2cb8d --- /dev/null +++ b/frontend/src/interfaces/DeviceAttributes.ts @@ -0,0 +1,7 @@ +export interface DeviceAttributes { + latitude: number; + longitude: number; + timeZone: string; // e.g. "America/Denver" + wellId: string; // e.g. "RA-3502" + depthToSensor: number; // feet (based on your data) +} diff --git a/frontend/src/interfaces/DevicePayload.ts b/frontend/src/interfaces/DevicePayload.ts new file mode 100644 index 00000000..7bf57c89 --- /dev/null +++ b/frontend/src/interfaces/DevicePayload.ts @@ -0,0 +1,8 @@ +import { DeviceAttributes, SensorData } from "./"; + +export interface DevicePayload { + deviceAttributes: DeviceAttributes; + sensorData: SensorData[]; + deviceName: string; + deviceId: string; +} diff --git a/frontend/src/interfaces/Measurement.ts b/frontend/src/interfaces/Measurement.ts new file mode 100644 index 00000000..fb516976 --- /dev/null +++ b/frontend/src/interfaces/Measurement.ts @@ -0,0 +1,4 @@ +export interface Measurement { + data: number; + timestamp: string; // "DD/MM/YYYY HH:mm:ss" (as provided) +} diff --git a/frontend/src/interfaces/SensorAttributes.ts b/frontend/src/interfaces/SensorAttributes.ts new file mode 100644 index 00000000..225abdf2 --- /dev/null +++ b/frontend/src/interfaces/SensorAttributes.ts @@ -0,0 +1,4 @@ +export interface SensorAttributes { + measurementUnit: string; // e.g. "feet", "Volt", "fahrenheit", ... + measurementUnitSymbol: string; // e.g. "ft", "V", "°F", "uS/cm", "psi" +} diff --git a/frontend/src/interfaces/SensorData.ts b/frontend/src/interfaces/SensorData.ts new file mode 100644 index 00000000..183a69e1 --- /dev/null +++ b/frontend/src/interfaces/SensorData.ts @@ -0,0 +1,8 @@ +import { SensorAttributes, Measurement } from "./"; + +export interface SensorData { + sensorId: string; // UUID + sensorName: string; // e.g. "Water Column Height" + attributes: SensorAttributes; + measurements: Measurement[]; +} diff --git a/frontend/src/interfaces/index.ts b/frontend/src/interfaces/index.ts new file mode 100644 index 00000000..4084e6d2 --- /dev/null +++ b/frontend/src/interfaces/index.ts @@ -0,0 +1,5 @@ +export * from "./DeviceAttributes"; +export * from "./DevicePayload"; +export * from "./Measurement"; +export * from "./SensorAttributes"; +export * from "./SensorData"; diff --git a/frontend/src/utils/DataStreamUtils.ts b/frontend/src/utils/DataStreamUtils.ts index 3939464d..85174c7a 100644 --- a/frontend/src/utils/DataStreamUtils.ts +++ b/frontend/src/utils/DataStreamUtils.ts @@ -1,5 +1,3 @@ -// import { MonitoredWell } from "../interfaces"; - const monitoredWellDataStreamIds: Record = { 1515: 25089, 1516: 25083, @@ -24,13 +22,3 @@ export const getDataStreamId = (wellId: number): number | undefined => { const datastream_id = monitoredWellDataStreamIds[wellId]; return datastream_id === -999 ? undefined : datastream_id; }; - -// export const getDataStreamId = ( -// wells: MonitoredWell[], -// wellId: number | undefined, -// ): number | undefined => { -// const wellDetails = wells.find((x) => x.id === wellId); -// if (!wellDetails) return undefined; -// if (wellDetails.datastream_id === -999) return undefined; -// return wellDetails.datastream_id; -// }; diff --git a/frontend/src/views/Activities/ActivityPhotoView.tsx b/frontend/src/views/Activities/ActivityPhotoView.tsx new file mode 100644 index 00000000..deba9508 --- /dev/null +++ b/frontend/src/views/Activities/ActivityPhotoView.tsx @@ -0,0 +1,83 @@ +import { useMemo, useState } from "react"; +import { useParams } from "react-router-dom"; +import { API_URL } from "../../config"; +import { Card, CardContent, Skeleton, Box, Alert } from "@mui/material"; +import { Image } from "@mui/icons-material"; +import { BackgroundBox, CustomCardHeader } from "../../components"; + +export const ActivityPhotoView = () => { + const { activity_id, photo_file_name } = useParams(); + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState(); + + const src = useMemo(() => { + if (!activity_id || !photo_file_name) return undefined; + return `${API_URL}/activities/${activity_id}/photos/${encodeURIComponent( + photo_file_name, + )}`; + }, [activity_id, photo_file_name]); + + if (!src) { + return ( + + + + + Missing photo parameters. + + + + ); + } + + return ( + + + + + {error && ( + + {error} + + )} + + + {/* Reserve space to prevent layout jump */} + {!loaded && !error && ( + + )} + + setLoaded(true)} + onError={() => { + setError("Failed to load photo."); + setLoaded(false); + }} + sx={{ + display: loaded && !error ? "block" : "none", + width: "100%", + height: "auto", + borderRadius: 1, + }} + /> + + + + + ); +}; diff --git a/frontend/src/views/Activities/MeterActivityEntry/ActivityFormConfig.ts b/frontend/src/views/Activities/MeterActivityEntry/ActivityFormConfig.ts index faef3499..72a6d894 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/ActivityFormConfig.ts +++ b/frontend/src/views/Activities/MeterActivityEntry/ActivityFormConfig.ts @@ -1,209 +1,213 @@ -import * as Yup from "yup" -import { ActivityForm, ActivityFormControl, MeterListDTO, ObservationForm } from '../../../interfaces.d' -import Dayjs from "dayjs" -import dayjs from "dayjs" - -// Form validation, these are applied to the current form when submitting -export const ActivityResolverSchema: Yup.ObjectSchema = Yup.object().shape({ - - activity_details: Yup.object().shape({ - selected_meter: Yup.object().shape({ - id: Yup.number().required(), - }).required("Please Select A Meter"), - - activity_type: Yup.object().shape({ - id: Yup.number().required("Please Select An Activity"), - }).required("Please Select An Activity"), - - user: Yup.object().shape({ - id: Yup.number().required("Please Select A User"), - }).required("Please Select a User"), - - date: Yup.date().required('Please Select a Date'), - start_time: Yup.date().required('Please Select a Start Time'), - end_time: Yup.date().required('Please Select an End Time') - - }).required(), - - current_installation: Yup.object().when('activity_details.activity_type.id', { - is: 1, - then: (schema) => schema.shape({ - meter: Yup.object().shape({ - id: Yup.number(), - }), - well: Yup.object().shape({ - id: Yup.number().required('Please select a well.'), - }).required('Please select a well.'), - }), - otherwise: (schema) => schema.shape({ - meter: Yup.object().shape({ - id: Yup.number(), - }), - well: Yup.object().shape({ - id: Yup.number().notRequired(), - }).notRequired(), - }) - }), - - observations: Yup.array().of(Yup.object().shape({ - time: Yup.date().required(), - reading: Yup.number().typeError('Please enter a number.').min(0, 'Please enter a non-negative value.').required('Please enter a value.'), - property_type: Yup.object().shape({ - id: Yup.number().required('Please select a property type.'), - }).required('Please select a property type.'), - unit: Yup.object().shape({ - id: Yup.number().required('Please select a unit.'), - }).required('Please select a unit.') - - })).required() - -}).required() - -// Convert the form control to the format expected by the backend -export function toSubmissionForm(activityFormControl: ActivityFormControl) { - const formData = new FormData(); - var observationForms: ObservationForm[] = [] - - activityFormControl.observations.forEach((observation: any) => { - observationForms.push({ - time: observation.time, - reading: observation.reading, - property_type_id: observation.property_type.id, - unit_id: observation.unit.id - }) - }) - - const activityForm: ActivityForm = { - activity_details: { - meter_id: activityFormControl?.activity_details?.selected_meter?.id, - activity_type_id: activityFormControl?.activity_details?.activity_type?.id, - user_id: activityFormControl?.activity_details?.user?.id, - date: activityFormControl?.activity_details?.date, - start_time: activityFormControl?.activity_details?.start_time, - end_time: activityFormControl?.activity_details?.end_time, - share_ose: activityFormControl?.activity_details?.share_ose, - work_order_id: activityFormControl?.activity_details?.work_order_id == null ? undefined : activityFormControl?.activity_details?.work_order_id - }, - current_installation: { - contact_name: activityFormControl?.current_installation?.meter?.contact_name as string, - contact_phone: activityFormControl?.current_installation?.meter?.contact_phone as string, - well_id: activityFormControl?.current_installation?.well?.id, - notes: activityFormControl?.current_installation?.meter?.notes as string, - water_users: activityFormControl?.current_installation?.meter?.water_users as string, - meter_owner: activityFormControl?.current_installation?.meter?.meter_owner as string - }, - observations: observationForms, - maintenance_repair: { - service_type_ids: activityFormControl.maintenance_repair?.service_type_ids ?? [], - description: activityFormControl.maintenance_repair?.description ?? '' - }, - notes: { - working_on_arrival_slug: activityFormControl.notes.working_on_arrival_slug, - selected_note_ids: activityFormControl.notes.selected_note_ids ?? [] - }, - part_used_ids: activityFormControl.part_used_ids ?? [] - } - - formData.append("activity", JSON.stringify(activityForm)); - - activityFormControl.photos?.forEach((file: File) => { - formData.append("photos", file); - }); - - return formData; -} - -// Provides the default values of the activity form -export function getDefaultForm(initialMeter: Partial | null, initialWorkOrderID: number | null = null): ActivityFormControl { - - //Generate start and end times using current time and end time 15min later - const start_time = Dayjs() - const end_time = Dayjs().add(15, 'minute') - - const defaultForm: ActivityFormControl = { - activity_details: { - selected_meter: initialMeter, - activity_type: null, - user: null, - date: Dayjs(), - start_time: start_time, - end_time: end_time, - share_ose: initialWorkOrderID ? true : false, - work_order_id: initialWorkOrderID - }, - - current_installation: { - meter: null, - well: null - }, - - // These should come from DB - observations: [ - { - time: dayjs.utc(), - reading: '', - property_type: { - id: 1, - units: [ - { - id: 1, name: 'Acre-feet', name_short: '...', description: '...' - }, - { - id: 2, name: 'Gallons', name_short: '...', description: '...' - } - ] - }, - unit: { id: 3 } - }, - { - time: dayjs.utc(), - reading: '', - property_type: { - id: 2, - units: [ - { - id: 3, name: 'Kilowatt hours', name_short: '...', description: '...' - }, - { - id: 4, name: 'Gas BTU', name_short: '...', description: '...' - } - ] - }, - unit: { id: 3 } - }, - { - time: dayjs.utc(), - reading: '', - property_type: { - id: 7, - units: [ - { - id: 11, name: 'Inches', name_short: '...', description: '...' - } - ] - }, - unit: { id: 7 } - }, - { - time: dayjs.utc(), - reading: '', - property_type: { - id: 3, - units: [ - { - id: 5, name: 'Percent', name_short: '...', description: '...' - } - ] - }, - unit: { id: 5 } - } - - ], - notes: { - working_on_arrival_slug: 'not-checked', - selected_note_ids: [] - } - } - - return defaultForm -} +import * as Yup from "yup"; +import { + ActivityForm, + ActivityFormControl, + MeterListDTO, + ObservationForm, +} from "../../../interfaces.d"; +import Dayjs from "dayjs"; +import dayjs from "dayjs"; + +// Form validation, these are applied to the current form when submitting +export const ActivityResolverSchema: Yup.ObjectSchema = Yup.object() + .shape({ + activity_details: Yup.object() + .shape({ + selected_meter: Yup.object() + .shape({ + id: Yup.number().required(), + }) + .required("Please Select A Meter"), + + activity_type: Yup.object() + .shape({ + id: Yup.number().required("Please Select An Activity"), + }) + .required("Please Select An Activity"), + + user: Yup.object() + .shape({ + id: Yup.number().required("Please Select A User"), + }) + .required("Please Select a User"), + + date: Yup.date().required("Please Select a Date"), + start_time: Yup.date().required("Please Select a Start Time"), + end_time: Yup.date().required("Please Select an End Time"), + }) + .required(), + + current_installation: Yup.object().when( + "activity_details.activity_type.id", + { + is: 1, + then: (schema) => + schema.shape({ + meter: Yup.object().shape({ + id: Yup.number(), + }), + well: Yup.object() + .shape({ + id: Yup.number().required("Please select a well."), + }) + .required("Please select a well."), + }), + otherwise: (schema) => + schema.shape({ + meter: Yup.object().shape({ + id: Yup.number(), + }), + well: Yup.object() + .shape({ + id: Yup.number().notRequired(), + }) + .notRequired(), + }), + }, + ), + + observations: Yup.array() + .of( + Yup.object().shape({ + time: Yup.date().required(), + reading: Yup.number() + .typeError("Please enter a number.") + .min(0, "Please enter a non-negative value.") + .required("Please enter a value."), + property_type_id: Yup.number() + .typeError("Please select a property type.") + .required("Please select a property type."), + unit_id: Yup.number() + .typeError("Please select a unit.") + .required("Please select a unit."), + }), + ) + .required(), + }) + .required(); + +// Convert the form control to the format expected by the backend +export function toSubmissionForm(activityFormControl: ActivityFormControl) { + const formData = new FormData(); + var observationForms: ObservationForm[] = []; + + activityFormControl.observations.forEach((observation: any) => { + observationForms.push({ + time: observation.time, + reading: observation.reading, + property_type_id: observation.property_type_id ?? "", + unit_id: observation.unit_id ?? "", + }); + }); + + const activityForm: ActivityForm = { + activity_details: { + meter_id: activityFormControl?.activity_details?.selected_meter?.id, + activity_type_id: + activityFormControl?.activity_details?.activity_type?.id, + user_id: activityFormControl?.activity_details?.user?.id, + date: activityFormControl?.activity_details?.date, + start_time: activityFormControl?.activity_details?.start_time, + end_time: activityFormControl?.activity_details?.end_time, + share_ose: activityFormControl?.activity_details?.share_ose, + work_order_id: + activityFormControl?.activity_details?.work_order_id == null + ? undefined + : activityFormControl?.activity_details?.work_order_id, + }, + current_installation: { + contact_name: activityFormControl?.current_installation?.meter + ?.contact_name as string, + contact_phone: activityFormControl?.current_installation?.meter + ?.contact_phone as string, + well_id: activityFormControl?.current_installation?.well?.id, + notes: activityFormControl?.current_installation?.meter?.notes as string, + water_users: activityFormControl?.current_installation?.meter + ?.water_users as string, + meter_owner: activityFormControl?.current_installation?.meter + ?.meter_owner as string, + }, + observations: observationForms, + maintenance_repair: { + service_type_ids: + activityFormControl.maintenance_repair?.service_type_ids ?? [], + description: activityFormControl.maintenance_repair?.description ?? "", + }, + notes: { + working_on_arrival_slug: + activityFormControl.notes.working_on_arrival_slug, + selected_note_ids: activityFormControl.notes.selected_note_ids ?? [], + }, + part_used_ids: activityFormControl.part_used_ids ?? [], + }; + + formData.append("activity", JSON.stringify(activityForm)); + + activityFormControl.photos?.forEach((file: File) => { + formData.append("photos", file); + }); + + return formData; +} + +// Provides the default values of the activity form +export function getDefaultForm( + initialMeter: Partial | null, + initialWorkOrderID: number | null = null, +): ActivityFormControl { + //Generate start and end times using current time and end time 15min later + const start_time = Dayjs(); + const end_time = Dayjs().add(15, "minute"); + + const defaultForm: ActivityFormControl = { + activity_details: { + selected_meter: initialMeter, + activity_type: null, + user: null, + date: Dayjs(), + start_time: start_time, + end_time: end_time, + share_ose: initialWorkOrderID ? true : false, + work_order_id: initialWorkOrderID, + }, + + current_installation: { + meter: null, + well: null, + }, + + // These should come from DB + observations: [ + { + time: dayjs.utc(), + reading: "", + property_type_id: 1, + unit_id: 1, + }, + { + time: dayjs.utc(), + reading: "", + property_type_id: 2, + unit_id: 3, + }, + { + time: dayjs.utc(), + reading: "", + property_type_id: 7, + unit_id: 11, + }, + { + time: dayjs.utc(), + reading: "", + property_type_id: 3, + unit_id: 5, + }, + ], + notes: { + working_on_arrival_slug: "not-checked", + selected_note_ids: [], + }, + }; + + return defaultForm; +} diff --git a/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx b/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx index 4e827e66..ae974f6e 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx @@ -37,13 +37,13 @@ export default function MeterActivityEntry() { const [isMeterAndActivitySelected, setIsMeterAndActivitySelected] = useState(false); - function onSuccessfulSubmit(activity_id: number, meter_id: number) { + const onSuccessfulSubmit = (activity_id: number, meter_id: number) => { enqueueSnackbar("Successfully Submitted Activity!", { variant: "success" }); navigate({ pathname: "/manage/meters", search: `?meter_id=${meter_id}&activity_id=${activity_id}`, }); - } + }; const createActivity = useMutation({ mutationFn: async (activityForm: FormData) => { @@ -85,7 +85,9 @@ export default function MeterActivityEntry() { onSuccess: (responseJson) => { const activity_id = responseJson.id; const meter_id = responseJson.meter_id; - enqueueSnackbar("Successfully Submitted Activity!", { variant: "success" }); + enqueueSnackbar("Successfully Submitted Activity!", { + variant: "success", + }); onSuccessfulSubmit(activity_id, meter_id); }, }); @@ -121,20 +123,19 @@ export default function MeterActivityEntry() { useEffect(() => { setHasMeterActivityConflict( - ( - meterDetails.data?.status.status_name == "Installed" && - watch("activity_details.activity_type")?.name == ActivityType.Install - ) || ( - meterDetails.data?.status.status_name != "Installed" && - watch("activity_details.activity_type")?.name == ActivityType.Uninstall - ), + (meterDetails.data?.status.status_name == "Installed" && + watch("activity_details.activity_type")?.name == + ActivityType.Install) || + (meterDetails.data?.status.status_name != "Installed" && + watch("activity_details.activity_type")?.name == + ActivityType.Uninstall), ); }, [meterDetails.data, watch("activity_details.activity_type")?.name]); useEffect(() => { setIsMeterAndActivitySelected( watch("activity_details.selected_meter") != null && - watch("activity_details.activity_type") != null, + watch("activity_details.activity_type") != null, ); }, [ watch("activity_details.selected_meter"), @@ -161,17 +162,56 @@ export default function MeterActivityEntry() { return ( - + {!hasMeterActivityConflict && isMeterAndActivitySelected ? ( - - - - - - + + + + + + {hasErrors(errors) ? ( - + Please correct any errors before submission. ) : ( diff --git a/frontend/src/views/Activities/MeterActivityEntry/ObservationsSelection.tsx b/frontend/src/views/Activities/MeterActivityEntry/ObservationsSelection.tsx index 61617959..6cf8a7c4 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/ObservationsSelection.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/ObservationsSelection.tsx @@ -1,38 +1,72 @@ import { useEffect } from "react"; -import { Box, Button, Grid, Typography } from "@mui/material"; -import DeleteIcon from "@mui/icons-material/Delete"; -import IconButton from "@mui/material/IconButton"; -import { useFieldArray } from "react-hook-form"; -import dayjs from "dayjs"; +import { Box, Button, Grid, Typography, IconButton } from "@mui/material"; +import { UseQueryResult } from "react-query"; +import { Delete } from "@mui/icons-material"; +import { useFieldArray, useWatch } from "react-hook-form"; import { ObservedPropertyTypeLU } from "../../../interfaces"; import { useGetPropertyTypes } from "../../../service/ApiServiceNew"; +import { ControlledSelectNonObject } from "../../../components/RHControlled/ControlledSelect"; import ControlledTimepicker from "../../../components/RHControlled/ControlledTimepicker"; import ControlledTextbox from "../../../components/RHControlled/ControlledTextbox"; -import { ControlledSelect } from "../../../components/RHControlled/ControlledSelect"; +import dayjs from "dayjs"; -function ObservationRow({ +const ObservationRow = ({ control, - watch, errors, fieldID, index, propertyTypes, remove, setValue, -}: any) { +}: { + control: any; + setValue: any; + errors: any; + + index: number; + fieldID: string; + remove: (index: number) => void; + + propertyTypes: UseQueryResult; +}) => { + const propertyTypeId = useWatch({ + control, + name: `observations.${index}.property_type_id`, + }); + + const unitId = useWatch({ + control, + name: `observations.${index}.unit_id`, + }); + + const propertyType = propertyTypes.data?.find( + (pt) => pt.id === propertyTypeId, + ); + useEffect(() => { - setValue( - `observations.${index}.unit`, - watch(`observations.${index}.property_type`)?.units?.at(0), - ); - setValue( - `observations.${index}.time`, - watch("activity_details.start_time"), - ); //Update the Match start time - }, [ - watch(`observations.${index}.property_type`), - watch("activity_details.start_time"), - ]); // Update the selected unit to the first in the newly selected property type + if ( + !propertyType || + !propertyType?.units || + propertyType?.units?.length === 0 + ) + return; + if (unitId != null) return; + + setValue(`observations.${index}.unit_id`, propertyType?.units[0].id, { + shouldDirty: false, + }); + }, [propertyType, unitId, index, setValue]); + + const startTime = useWatch({ + control, + name: "activity_details.start_time", + }); + + useEffect(() => { + if (!startTime) return; + + setValue(`observations.${index}.time`, startTime, { shouldDirty: false }); + }, [startTime, index, setValue]); return ( @@ -46,13 +80,15 @@ function ObservationRow({ /> - p.name} - error={errors?.observations?.at(index)?.property_type?.message} + label="Reading Type" + options={propertyTypes.data?.map((pt) => pt.id) ?? []} + getOptionLabel={(id: number) => + propertyTypes.data?.find((pt) => pt.id === id)?.name ?? "" + } + error={errors?.observations?.[index]?.property_type_id?.message} /> @@ -67,54 +103,48 @@ function ObservationRow({ /> - u.id) ?? []} + getOptionLabel={(id: number) => + propertyType?.units?.find((u) => u.id === id)?.name ?? "" } - getOptionLabel={(p: ObservedPropertyTypeLU) => p.name} - error={errors?.observations?.at(index)?.unit?.message} + error={errors?.observations?.[index]?.unit_id?.message} /> - + remove(index)} > - + )} ); -} +}; export default function ObservationSelection({ control, errors, - watch, setValue, }: any) { - const propertyTypes: any = useGetPropertyTypes(); + const propertyTypes: UseQueryResult = + useGetPropertyTypes(); - // React hook formarray const { fields, append, remove } = useFieldArray({ control, name: "observations", }); - function addObservation() { - append({ - time: dayjs().utc(), - reading: "", - property_type: null, - unit: null, - }); - } - return ( @@ -125,7 +155,6 @@ export default function ObservationSelection({ return ( ); })} -