diff --git a/api/main.py b/api/main.py index fd0a70a9..b89c6411 100644 --- a/api/main.py +++ b/api/main.py @@ -1,28 +1,11 @@ -# =============================================================================== -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== from datetime import timedelta - from fastapi import FastAPI, Depends, HTTPException from fastapi.security import OAuth2PasswordRequestForm from fastapi_pagination import add_pagination - from fastapi.middleware.cors import CORSMiddleware 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.admin import admin_router from api.routes.chlorides import authenticated_chlorides_router, public_chlorides_router @@ -33,14 +16,12 @@ from api.routes.settings import settings_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, create_access_token, ACCESS_TOKEN_EXPIRE_HOURS, authenticated_router, ) - from api.session import get_db from sqlalchemy.orm import Session diff --git a/api/models/main_models.py b/api/models/main_models.py index d174d471..ea192863 100644 --- a/api/models/main_models.py +++ b/api/models/main_models.py @@ -546,13 +546,13 @@ class WellMeasurements(Base): timestamp: Mapped[DateTime] = mapped_column( DateTime, default=func.now(), nullable=False ) - value: Mapped[float] = mapped_column(Float, nullable=False) + value: Mapped[Optional[float]] = mapped_column(Float, nullable=True) observed_property_id: Mapped[int] = mapped_column( Integer, ForeignKey("ObservedPropertyTypeLU.id"), nullable=False ) - submitting_user_id: Mapped[int] = mapped_column( - Integer, ForeignKey("Users.id"), nullable=False + submitting_user_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("Users.id"), nullable=True ) unit_id: Mapped[int] = mapped_column( Integer, ForeignKey("Units.id"), nullable=False diff --git a/api/routes/chlorides.py b/api/routes/chlorides.py index 27234742..44dd32b2 100644 --- a/api/routes/chlorides.py +++ b/api/routes/chlorides.py @@ -1,11 +1,10 @@ from typing import Optional, List -from datetime import datetime -import calendar +from datetime import datetime, date import statistics from fastapi.responses import StreamingResponse from weasyprint import HTML from io import BytesIO -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, Query from pydantic import BaseModel from sqlalchemy import and_, select from sqlalchemy.orm import Session, joinedload @@ -117,16 +116,8 @@ class ChlorideReportNums(BaseModel): tags=["Chlorides"], ) def get_chlorides_report( - from_month: Optional[str] = Query( - None, - description="Month start, 'YYYY-MM'", - pattern=r"^$|^\d{4}-\d{2}$", - ), - to_month: Optional[str] = Query( - None, - description="Month end, 'YYYY-MM'", - pattern=r"^$|^\d{4}-\d{2}$", - ), + from_date: date = Query(..., description="Start date in ISO format, 'YYYY-MM-DD'"), + to_date: date = Query(..., description="End date in ISO format, 'YYYY-MM-DD'"), db: Session = Depends(get_db), ): """ @@ -136,13 +127,9 @@ def get_chlorides_report( CHLORIDE_OBSERVED_PROPERTY_ID = 5 - # Parse months - start_dt = _parse_month(from_month) if from_month else None - end_dt = _parse_month(to_month) if to_month else None - if start_dt and not end_dt: - end_dt = start_dt - if end_dt: - end_dt = _month_end(end_dt) + # Convert to datetimes for inclusive range + start_dt = datetime.combine(from_date, datetime.min.time()) + end_dt = datetime.combine(to_date, datetime.max.time()) stmt = ( select( @@ -218,31 +205,23 @@ def get_chlorides_report( tags=["Chlorides"], ) def download_chlorides_report_pdf( - from_month: Optional[str] = Query( - None, - description="Month start, 'YYYY-MM'", - pattern=r"^$|^\d{4}-\d{2}$", - ), - to_month: Optional[str] = Query( - None, - description="Month end, 'YYYY-MM'", - pattern=r"^$|^\d{4}-\d{2}$", - ), + from_date: date = Query(..., description="Start date in ISO format, 'YYYY-MM-DD'"), + to_date: date = Query(..., description="End date in ISO format, 'YYYY-MM-DD'"), db: Session = Depends(get_db), ): """ Generate a PDF chloride report (north/south/east/west stats) for the SE quadrant of New Mexico. """ - # Re-use your existing logic by calling the data endpoint’s function - report = get_chlorides_report(from_month=from_month, to_month=to_month, db=db) + # Re-use existing logic + report = get_chlorides_report(from_date=from_date, to_date=to_date, db=db) # Render HTML using a template template = templates.get_template("chlorides_report.html") html_content = template.render( report=report, - from_month=from_month, - to_month=to_month, + from_date=from_date, + to_date=to_date, ) # Convert to PDF @@ -293,12 +272,13 @@ def patch_chloride_measurement( chloride_measurement_patch: well_schemas.PatchChlorideMeasurement, db: Session = Depends(get_db), ): - # Find the measurement well_measurement = ( - db.scalars(select(WellMeasurements).where(WellMeasurements.id == chloride_measurement_patch.id)).first() + db.scalars( + select(WellMeasurements) + .where(WellMeasurements.id == chloride_measurement_patch.id) + ).first() ) - # Update the fields, all are mandatory well_measurement.submitting_user_id = chloride_measurement_patch.submitting_user_id well_measurement.timestamp = chloride_measurement_patch.timestamp well_measurement.value = chloride_measurement_patch.value @@ -326,26 +306,6 @@ def delete_chloride_measurement(chloride_measurement_id: int, db: Session = Depe return True -def _parse_month(m: Optional[str]) -> Optional[datetime]: - """ - Accepts 'YYYY-MM' or 'YYYY MM'. Returns the first day of month at 00:00:00. - """ - if not m: - return None - m = m.strip() - # Try 'YYYY-MM' - for fmt in ("%Y-%m", "%Y %m"): - try: - dt = datetime.strptime(m, fmt) - return dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - except ValueError: - continue - raise HTTPException(status_code=400, detail="Invalid month format. Use 'YYYY-MM' or 'YYYY MM'.") - -def _month_end(dt: datetime) -> datetime: - last_day = calendar.monthrange(dt.year, dt.month)[1] - return dt.replace(day=last_day, hour=23, minute=59, second=59, microsecond=999999) - def _stats(values: List[Optional[float]]) -> MinMaxAvgMedCount: clean = [v for v in values if v is not None] if not clean: diff --git a/api/routes/maintenance.py b/api/routes/maintenance.py index 104fb77e..b4b9efe1 100644 --- a/api/routes/maintenance.py +++ b/api/routes/maintenance.py @@ -1,9 +1,8 @@ -from fastapi import Depends, APIRouter, HTTPException, Query +from fastapi import Depends, APIRouter, Query from sqlalchemy.orm import Session from pydantic import BaseModel from typing import List -from datetime import datetime -import calendar +from datetime import datetime, date from fastapi.responses import StreamingResponse from weasyprint import HTML from io import BytesIO @@ -63,31 +62,20 @@ class MaintenanceSummaryResponse(BaseModel): dependencies=[Depends(ScopedUser.Read)], ) def get_maintenance_summary( - from_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), - to_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + from_date: date = Query(..., description="Start 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), ): - # Parse from/to month into datetime range - try: - from_date = datetime.strptime(from_month, "%Y-%m").replace(day=1) - to_dt = datetime.strptime(to_month, "%Y-%m") - year, month = to_dt.year, to_dt.month - today = datetime.now() + """ + Returns min/max/avg for north/south/east/west halves **within the SE quadrant of New Mexico**, + over the specified [from_date, to_date] inclusive range. + """ + # Convert to datetimes for inclusive range + start_dt = datetime.combine(from_date, datetime.min.time()) + end_dt = datetime.combine(to_date, datetime.max.time()) - if year == today.year and month == today.month: - to_date = today - else: - last_day = calendar.monthrange(year, month)[1] - to_date = to_dt.replace(day=last_day, hour=23, minute=59, second=59) - except ValueError: - raise HTTPException( - status_code=400, - detail="Invalid date format. Use YYYY-MM." - ) - - # Filter by technicians if -1 is not present filter_techs = -1 not in technicians # Optional TRSS-based meter filtering @@ -96,14 +84,11 @@ def get_maintenance_summary( try: # normalize input (strip spaces) trss_str = trss.strip() - - location_ids = ( - db.query(Locations.id) + location_ids = [ + loc_id for (loc_id,) in db.query(Locations.id) .filter(Locations.trss.like(f"{trss_str}%")) .all() - ) - location_ids = [loc_id for (loc_id,) in location_ids] - + ] if location_ids: meter_subq = ( db.query(Meters.id) @@ -113,7 +98,6 @@ def get_maintenance_summary( except Exception: pass # Ignore invalid TRSS input silently - # Base query query = ( db.query( MeterActivities.timestamp_start.label("date_time"), @@ -129,8 +113,8 @@ def get_maintenance_summary( ActivityTypeLU.id == MeterActivities.activity_type_id ) .join(Locations, Locations.id == Meters.location_id, isouter=True) - .filter(MeterActivities.timestamp_start >= from_date) - .filter(MeterActivities.timestamp_start <= to_date) + .filter(MeterActivities.timestamp_start >= start_dt) + .filter(MeterActivities.timestamp_start <= end_dt) ) if filter_techs: @@ -140,7 +124,6 @@ def get_maintenance_summary( if matching_meter_ids is not None: if not matching_meter_ids: - # TRSS valid but no meters matched → return empty results return { "repairs_by_meter": [], "pms_by_meter": [], @@ -150,7 +133,6 @@ def get_maintenance_summary( base_query = query.order_by(MeterActivities.timestamp_start).all() - # Aggregate repairs and PMs repairs_by_meter = defaultdict(int) pms_by_meter = defaultdict(int) grouped_rows = defaultdict(lambda: {"number_of_repairs": 0, "number_of_pms": 0}) @@ -164,16 +146,16 @@ def get_maintenance_summary( pms_by_meter[row.meter] += 1 grouped_rows[key]["number_of_pms"] += 1 - repairs_result = [{"meter": meter, "count": count} for meter, count in repairs_by_meter.items()] - pms_result = [{"meter": meter, "count": count} for meter, count in pms_by_meter.items()] + 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()] table_rows = [] - for (date_time, technician, meter, trss), counts in grouped_rows.items(): + 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 or "", + "trss": trss_val or "", "number_of_repairs": counts["number_of_repairs"], "number_of_pms": counts["number_of_pms"], }) @@ -190,105 +172,27 @@ def get_maintenance_summary( tags=["Maintenance"], dependencies=[Depends(ScopedUser.Read)], ) -def download_parts_used_pdf( - from_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), - to_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), +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"), trss: str = Query(...), technicians: List[int] = Query(...), db: Session = Depends(get_db), ): - try: - from_date = datetime.strptime(from_month, "%Y-%m").replace(day=1) - to_dt = datetime.strptime(to_month, "%Y-%m") - year, month = to_dt.year, to_dt.month - today = datetime.now() - if year == today.year and month == today.month: - to_date = today - else: - last_day = calendar.monthrange(year, month)[1] - to_date = to_dt.replace(day=last_day, hour=23, minute=59, second=59) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM.") - - filter_techs = -1 not in technicians - - # Optional TRSS filtering via Locations → Meters - matching_meter_ids = None - if trss: - try: - parts = list(map(int, trss.strip().split("."))) - if len(parts) >= 4: - township, range_, section, quarter = parts[:4] - - location_ids = [ - loc_id for (loc_id,) in db.query(Locations.id).filter( - Locations.township == township, - Locations.range == range_, - Locations.section == section, - Locations.quarter == quarter, - ).all() - ] - - if location_ids: - matching_meter_ids = [ - meter_id for (meter_id,) in db.query(Meters.id).filter( - Meters.location_id.in_(location_ids) - ).all() - ] - except Exception: - pass # Silently skip TRSS filtering if malformed - - query = ( - db.query( - MeterActivities.timestamp_start.label("date_time"), - Users.full_name.label("technician"), - Meters.serial_number.label("meter"), - ActivityTypeLU.name.label("activity_type") - ) - .join(Users, Users.id == MeterActivities.submitting_user_id) - .join(Meters, Meters.id == MeterActivities.meter_id) - .join( - ActivityTypeLU, - ActivityTypeLU.id == MeterActivities.activity_type_id - ) - .filter(MeterActivities.timestamp_start >= from_date) - .filter(MeterActivities.timestamp_start <= to_date) + """ + Generate a PDF maintenance summary between two dates. + Reuses the JSON endpoint's logic to avoid duplication. + """ + # Re-use the endpoint logic directly + summary = get_maintenance_summary( + from_date=from_date, + to_date=to_date, + trss=trss, + technicians=technicians, + db=db, ) - if filter_techs: - query = query.filter(MeterActivities.submitting_user_id.in_(technicians)) - - if matching_meter_ids is not None: - if not matching_meter_ids: - return StreamingResponse(BytesIO(), media_type="application/pdf") # Empty PDF - query = query.filter(MeterActivities.meter_id.in_(matching_meter_ids)) - - base_query = query.order_by(MeterActivities.timestamp_start).all() - - repairs_by_meter = defaultdict(int) - pms_by_meter = defaultdict(int) - grouped_rows = defaultdict(lambda: {"number_of_repairs": 0, "number_of_pms": 0}) - - for row in base_query: - key = (row.date_time, row.technician, row.meter) - if row.activity_type == "Repair": - repairs_by_meter[row.meter] += 1 - grouped_rows[key]["number_of_repairs"] += 1 - elif row.activity_type == "Preventative Maintenance": - pms_by_meter[row.meter] += 1 - grouped_rows[key]["number_of_pms"] += 1 - - table_rows = [] - for (date_time, technician, meter), counts in grouped_rows.items(): - table_rows.append({ - "date_time": date_time.strftime("%Y-%m-%d %H:%M"), - "technician": technician, - "meter": meter, - "number_of_repairs": counts["number_of_repairs"], - "number_of_pms": counts["number_of_pms"], - }) - - # Generate pie charts as base64 PNGs + # Make pie charts as base64 PNGs def make_pie_chart(data: dict, title: str): if not data: return "" @@ -306,16 +210,22 @@ def make_pie_chart(data: dict, title: str): close(fig) return b64encode(buf.getvalue()).decode("utf-8") - repair_chart_b64 = make_pie_chart(repairs_by_meter, "Repairs by Meter") - pm_chart_b64 = make_pie_chart(pms_by_meter, "Preventative Maintenances by Meter") + 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" + ) template = templates.get_template("maintenance_summary.html") html = template.render( - from_month=from_month, - to_month=to_month, + from_date=from_date, + to_date=to_date, repair_chart=repair_chart_b64, pm_chart=pm_chart_b64, - table_rows=table_rows, + table_rows=summary["table_rows"], ) pdf_io = BytesIO() diff --git a/api/routes/meters.py b/api/routes/meters.py index cc0b8b75..9f78258d 100644 --- a/api/routes/meters.py +++ b/api/routes/meters.py @@ -1,15 +1,11 @@ from typing import List - from fastapi import Depends, APIRouter, HTTPException, Query from sqlalchemy import or_, select, desc, and_, text from sqlalchemy.orm import Session, joinedload from sqlalchemy.exc import IntegrityError from fastapi_pagination.ext.sqlalchemy import paginate from fastapi_pagination import LimitOffsetPage -from fastapi.responses import StreamingResponse from enum import Enum -from io import BytesIO -from jose import jwt, JWTError, ExpiredSignatureError from api.schemas import meter_schemas from api.schemas import well_schemas from api.models.main_models import ( @@ -27,18 +23,16 @@ from api.route_util import _patch, _get from api.session import get_db from api.enums import ScopedUser, MeterSortByField, MeterStatus, SortDirection +from google.auth import default, impersonated_credentials from google.cloud import storage +from datetime import timedelta -import time -import mimetypes import os authenticated_meter_router = APIRouter() public_meter_router = APIRouter() # Generate random secret at startup -PHOTO_JWT_SECRET = os.getenv("PHOTO_JWT_SECRET", "super-secret") -PHOTO_JWT_ALGORITHM = "HS256" PHOTO_JWT_EXPIRE_SECONDS = 600 # 10 minutes BUCKET_NAME = os.getenv("GCP_BUCKET_NAME", "") @@ -107,10 +101,7 @@ def sort_by_field_to_schema_field(name: MeterSortByField): else: query_statement = query_statement.order_by( schema_field_name - ) # SQLAlchemy orders ascending by default - - # Print the SQL query for debugging - #print(query_statement) + ) return paginate(db, query_statement) @@ -456,37 +447,6 @@ def patch_meter( ).first() -@public_meter_router.get("/photos/{path:path}") -def get_photo(path: str, token: str = Query(...)): - # raises 401 if invalid - blob_path = verify_photo_token(token) - - # extra guard: token "sub" must match requested path - if blob_path != path: - raise HTTPException(status_code=403, detail="Token path mismatch") - - client = storage.Client() - bucket = client.bucket(BUCKET_NAME) - blob = bucket.blob(path) - - if not blob.exists(): - raise HTTPException(status_code=404, detail="Photo not found") - - content_type, _ = mimetypes.guess_type(path) - content_type = content_type or "application/octet-stream" - - data = blob.download_as_bytes() - - return StreamingResponse( - BytesIO(data), - media_type=content_type, - headers={ - "Cache-Control": f"public, max-age={PHOTO_JWT_EXPIRE_SECONDS}", - "ETag": blob.etag or "", - }, - ) - - @authenticated_meter_router.get( "/meter_history", dependencies=[Depends(ScopedUser.Read)], tags=["Meters"] ) @@ -543,7 +503,7 @@ class HistoryType(Enum): { "id": photo.id, "file_name": photo.file_name, - "url": f"/photos/{photo.gcs_path}?token={create_photo_token(photo.gcs_path)}", + "url": create_signed_url(photo.gcs_path), "uploaded_at": photo.uploaded_at, } for photo in activity.photos @@ -588,23 +548,24 @@ class HistoryType(Enum): return formattedHistoryItems -def create_photo_token(blob_path: str) -> str: - expire = int(time.time()) + PHOTO_JWT_EXPIRE_SECONDS - payload = {"sub": blob_path, "exp": expire} - token = jwt.encode(payload, PHOTO_JWT_SECRET, algorithm=PHOTO_JWT_ALGORITHM) - return token +def create_signed_url(blob_path: str) -> str: + """Create a v4 signed URL for a blob in GCS.""" + source_creds, _ = default() + target_sa = "pvacd-meterapp@waterdatainitiative-271000.iam.gserviceaccount.com" + creds = impersonated_credentials.Credentials( + source_credentials=source_creds, + target_principal=target_sa, + target_scopes=['https://www.googleapis.com/auth/devstorage.read_only'], + lifetime=3600, + ) -def verify_photo_token(token: str) -> str: - try: - payload = jwt.decode( - token, - PHOTO_JWT_SECRET, - algorithms=[PHOTO_JWT_ALGORITHM] - ) - return payload["sub"] - except ExpiredSignatureError: - raise HTTPException(status_code=401, detail="Token expired") - except JWTError as e: - print("Token verification failed:", str(e)) - raise HTTPException(status_code=401, detail="Invalid token") + storage_client = storage.Client(credentials=creds) + bucket = storage_client.bucket(BUCKET_NAME) + blob = bucket.blob(blob_path) + url = blob.generate_signed_url( + version="v4", + expiration=timedelta(seconds=PHOTO_JWT_EXPIRE_SECONDS), + method="GET", + ) + return url diff --git a/api/routes/parts.py b/api/routes/parts.py index 3eb9325c..42ece499 100644 --- a/api/routes/parts.py +++ b/api/routes/parts.py @@ -2,8 +2,7 @@ from sqlalchemy.orm import Session, joinedload from sqlalchemy import select, func from typing import List, Union, Optional -from datetime import datetime -import calendar +from datetime import datetime, date from fastapi.responses import StreamingResponse from weasyprint import HTML from io import BytesIO @@ -62,35 +61,14 @@ def get_parts( dependencies=[Depends(ScopedUser.Read)], ) def get_parts_used_summary( - from_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), - to_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + from_date: date = Query(..., description="Start date YYYY-MM-DD"), + to_date: date = Query(..., description="End date YYYY-MM-DD"), parts: List[int] = Query(...), db: Session = Depends(get_db), ): - try: - # Parse and normalize start of "from" month - from_date = datetime.strptime(from_month, "%Y-%m").replace(day=1) - - # Determine end of "to" month - to_dt = datetime.strptime(to_month, "%Y-%m") - year, month = to_dt.year, to_dt.month - today = datetime.now() - - if year == today.year and month == today.month: - to_date = today - else: - last_day = calendar.monthrange(year, month)[1] - to_date = to_dt.replace( - day=last_day, - hour=23, - minute=59, - second=59 - ) - except ValueError: - raise HTTPException( - status_code=400, - detail="Invalid date format. Use YYYY-MM." - ) + # Convert to datetimes for inclusive range + start_dt = datetime.combine(from_date, datetime.min.time()) + end_dt = datetime.combine(to_date, datetime.max.time()) usage_subq = ( db.query( @@ -102,8 +80,8 @@ def get_parts_used_summary( MeterActivities.id == PartsUsed.c.meter_activity_id ) .filter( - MeterActivities.timestamp_start >= from_date, - MeterActivities.timestamp_start <= to_date, + MeterActivities.timestamp_start >= start_dt, + MeterActivities.timestamp_start <= end_dt, PartsUsed.c.part_id.in_(parts), ) .group_by(PartsUsed.c.part_id) @@ -126,13 +104,14 @@ def get_parts_used_summary( results = [] for row in query.all(): price = row.price or 0 - total = price * row.quantity + quantity = row.quantity or 0 + total = price * quantity results.append({ "id": row.id, "part_number": row.part_number, "description": row.description, "price": price, - "quantity": row.quantity, + "quantity": quantity, "total": total, }) @@ -145,85 +124,25 @@ def get_parts_used_summary( dependencies=[Depends(ScopedUser.Read)], ) def download_parts_used_pdf( - from_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), - to_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + from_date: date = Query(..., description="Start date YYYY-MM-DD"), + to_date: date = Query(..., description="End date YYYY-MM-DD"), parts: List[int] = Query(...), db: Session = Depends(get_db), ): - try: - from_date = datetime.strptime(from_month, "%Y-%m").replace(day=1) - to_dt = datetime.strptime(to_month, "%Y-%m") - year, month = to_dt.year, to_dt.month - today = datetime.now() - - if year == today.year and month == today.month: - to_date = today - else: - last_day = calendar.monthrange(year, month)[1] - to_date = to_dt.replace( - day=last_day, - hour=23, - minute=59, - second=59 - ) - except ValueError: - raise HTTPException( - status_code=400, - detail="Invalid date format. Use YYYY-MM." - ) + # Re-use your existing logic + results = get_parts_used_summary(from_date=from_date, to_date=to_date, parts=parts, db=db) - usage_subq = ( - db.query( - PartsUsed.c.part_id.label("used_part_id"), - func.count(PartsUsed.c.part_id).label("quantity") - ) - .join( - MeterActivities, - MeterActivities.id == PartsUsed.c.meter_activity_id - ) - .filter( - MeterActivities.timestamp_start >= from_date, - MeterActivities.timestamp_start <= to_date, - PartsUsed.c.part_id.in_(parts), - ) - .group_by(PartsUsed.c.part_id) - .subquery() - ) - - query = ( - db.query( - Parts.id.label("id"), - Parts.part_number, - Parts.description, - Parts.price, - func.coalesce(usage_subq.c.quantity, 0).label("quantity") - ) - .outerjoin(usage_subq, Parts.id == usage_subq.c.used_part_id) - .filter(Parts.id.in_(parts)) - .order_by(Parts.part_number) - ) - - results = [] + # Add running total just for PDF running_total = 0.0 - for row in query.all(): - price = row.price or 0 - quantity = row.quantity or 0 - total = price * quantity - running_total += total - results.append({ - "part_number": row.part_number, - "description": row.description, - "price": price, - "quantity": quantity, - "total": total, - "running_total": running_total, - }) + for r in results: + running_total += r["total"] + r["running_total"] = running_total template = templates.get_template("parts_used_report.html") html_content = template.render( rows=results, - from_month=from_month, - to_month=to_month + from_date=from_date, + to_date=to_date, ) pdf_io = BytesIO() HTML(string=html_content).write_pdf(pdf_io) @@ -307,7 +226,7 @@ def update_part(updated_part: part_schemas.Part, db: Session = Depends(get_db)): try: db.add(part_db) db.commit() - except IntegrityError as e: + except IntegrityError: raise HTTPException(status_code=409, detail="Part SN already exists") # Load the updated part to get the relationships @@ -353,7 +272,7 @@ def create_part(new_part: part_schemas.Part, db: Session = Depends(get_db)): try: db.add(new_part_model) db.commit() - except IntegrityError as e: + except IntegrityError: raise HTTPException(status_code=409, detail="Part SN already exists") # Associate with meter types diff --git a/api/routes/well_measurements.py b/api/routes/well_measurements.py index 7f19b7f5..fe7e2fb4 100644 --- a/api/routes/well_measurements.py +++ b/api/routes/well_measurements.py @@ -1,6 +1,5 @@ from typing import List, Optional -from datetime import datetime -import calendar +from datetime import datetime, date import re from fastapi import Depends, APIRouter, Query, HTTPException @@ -18,13 +17,19 @@ 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 +import json +import os import matplotlib + matplotlib.use("Agg") # Force non-GUI backend +WOODPECKER_BUCKET_NAME = os.getenv("GCP_WOODPECKER_BUCKET_NAME", "") + TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" templates = Environment( @@ -67,6 +72,46 @@ def add_waterlevel( return well_measurement +@public_well_measurement_router.get( + "/waterlevels/woodpeckers", + response_model=List[well_schemas.WellMeasurementDTO], + tags=["WaterLevels"], +) +def read_woodpecker_waterlevels( + well_id: int = Query(..., description="At least one well ID is required"), +): + SP_JOHNSON_WELL_ID = 2599 + + if well_id != SP_JOHNSON_WELL_ID: + raise HTTPException(status_code=400, detail="Invalid well ID") + + 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"] + ), + ) + results.append(measurement) + + return results + + @public_well_measurement_router.get( "/waterlevels", response_model=List[well_schemas.WellMeasurementDTO], @@ -74,115 +119,129 @@ def add_waterlevel( ) def read_waterlevels( well_ids: List[int] = Query(..., description="One or more well IDs"), - from_month: Optional[str] = Query(None, pattern=r"^$|^\d{4}-\d{2}$"), - to_month: Optional[str] = Query(None, pattern=r"^$|^\d{4}-\d{2}$"), + from_date: Optional[date] = Query( + None, description="Start date in ISO format, 'YYYY-MM-DD' (optional)" + ), + to_date: Optional[date] = Query( + None, description="End date in ISO format, 'YYYY-MM-DD' (optional)" + ), isAveragingAllWells: bool = Query(False), isComparingTo1970Average: bool = Query(False), comparisonYear: Optional[str] = Query(None, pattern=r"^$|^\d{4}$"), db: Session = Depends(get_db), ): + """ + Return well measurements, optionally filtered by from_date/to_date, + with optional averaging and historical comparison. + """ MONITORING_USE_TYPE_ID = 11 synthetic_id_counter = -1 def group_and_average(measurements, group_by_label: str): + from collections import defaultdict grouped = defaultdict(list) for m in measurements: - key = m.timestamp.strftime("%Y-%m" if group_by_label == "month" else "%Y-%m-%d") + key = m.timestamp.strftime( + "%Y-%m" if group_by_label == "month" else "%Y-%m-%d" + ) grouped[key].append(m.value) result = [] for time_str, values in sorted(grouped.items()): - dt = datetime.strptime(time_str, "%Y-%m" if group_by_label == "month" else "%Y-%m-%d") + dt = datetime.strptime( + time_str, + "%Y-%m" if group_by_label == "month" else "%Y-%m-%d", + ) avg_value = sum(values) / len(values) nonlocal synthetic_id_counter - result.append(well_schemas.WellMeasurementDTO( - id=synthetic_id_counter, - timestamp=dt, - value=avg_value, - submitting_user={"full_name": "System"}, - well={"ra_number": "Average of wells"} - )) + result.append( + well_schemas.WellMeasurementDTO( + id=synthetic_id_counter, + timestamp=dt, + value=avg_value, + submitting_user={"full_name": "System"}, + well={"ra_number": "Average of wells"}, + ) + ) synthetic_id_counter -= 1 return result - def get_measurements_by_ids(well_ids, start, end): + def get_measurements_by_ids(well_ids, start: Optional[date], end: Optional[date]): + filters = [ + ObservedPropertyTypeLU.name == "Depth to water", + WellMeasurements.well_id.in_(well_ids), + ] + if start: + filters.append(WellMeasurements.timestamp >= start) + if end: + # include full day when end is provided + end_dt = datetime.combine(end, datetime.max.time()) + filters.append(WellMeasurements.timestamp <= end_dt) + stmt = ( select(WellMeasurements) - .options(joinedload(WellMeasurements.submitting_user), joinedload(WellMeasurements.well)) - .join(ObservedPropertyTypeLU) - .where( - and_( - ObservedPropertyTypeLU.name == "Depth to water", - WellMeasurements.well_id.in_(well_ids), - *( [WellMeasurements.timestamp >= start] if start else [] ), - *( [WellMeasurements.timestamp <= end] if end else [] ), - ) + .options( + joinedload(WellMeasurements.submitting_user), + joinedload(WellMeasurements.well), ) + .join(ObservedPropertyTypeLU) + .where(and_(*filters)) .order_by(WellMeasurements.well_id, WellMeasurements.timestamp) ) return db.scalars(stmt).all() - # Helper: add a comparison average for any given year (same rules as 1970) - def add_year_average(year: int, label: str): - # Determine comparison window shape based on requested range size - if (to_date - from_date).days >= 365: - start = datetime(year, 1, 1) - end = datetime(year, 12, 31, 23, 59, 59) - else: - start = datetime(year, from_date.month, 1) - last_day = calendar.monthrange(year, to_date.month)[1] - end = datetime(year, to_date.month, last_day, 23, 59, 59) - - monitoring_ids = [ - row[0] for row in db.execute( - select(Wells.id).where(Wells.use_type_id == MONITORING_USE_TYPE_ID) - ).all() - ] - year_measurements = get_measurements_by_ids(monitoring_ids, start, end) - averaged = group_and_average(year_measurements, "month") # Always by month - for dto in averaged: - dto.well.ra_number = label - response_data.extend(averaged) - - # Parse dates - from_date, to_date = None, None - if from_month and to_month: - try: - from_date = datetime.strptime(from_month, "%Y-%m").replace(day=1) - to_dt = datetime.strptime(to_month, "%Y-%m") - today = datetime.now() - if to_dt.year == today.year and to_dt.month == today.month: - to_date = today - else: - last_day = calendar.monthrange(to_dt.year, to_dt.month)[1] - to_date = to_dt.replace(day=last_day, hour=23, minute=59, second=59) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM.") + # Decide grouping granularity only if both dates are given + group_by = None + if from_date and to_date: + group_by = "month" if (to_date - from_date).days >= 365 else "day" if not well_ids and not isComparingTo1970Average and not comparisonYear: return [] - group_by = None - if from_month and to_month: - group_by = "month" if (to_date - from_date).days >= 365 else "day" - - response_data = [] + response_data: List[well_schemas.WellMeasurementDTO] = [] # Averaged selection (if requested) if isAveragingAllWells and well_ids: current_measurements = get_measurements_by_ids(well_ids, from_date, to_date) - averaged = group_and_average(current_measurements, group_by) + averaged = group_and_average(current_measurements, group_by or "day") response_data.extend(averaged) # Raw per-well (if not averaging) if not isAveragingAllWells and well_ids: response_data.extend(get_measurements_by_ids(well_ids, from_date, to_date)) - # 1970 comparison (existing behavior) + # Helper: add a comparison average for any given year + def add_year_average(year: int, label: str): + # pick full year or same-month window depending on user’s range + if from_date and to_date and (to_date - from_date).days >= 365: + start = datetime(year, 1, 1) + end = datetime(year, 12, 31, 23, 59, 59) + else: + # fallback: use provided month(s) if available, otherwise full year + 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: + start = datetime(year, 1, 1) + end = datetime(year, 12, 31, 23, 59, 59) + + monitoring_ids = [ + row[0] + for row in db.execute( + select(Wells.id).where(Wells.use_type_id == MONITORING_USE_TYPE_ID) + ).all() + ] + year_measurements = get_measurements_by_ids(monitoring_ids, start, end) + averaged = group_and_average(year_measurements, "month") + for dto in averaged: + dto.well.ra_number = label + response_data.extend(averaged) + if isComparingTo1970Average: add_year_average(1970, "1970 Average") - # Dynamic comparison year (NEW) if comparisonYear: try: year_int = int(comparisonYear) @@ -191,11 +250,12 @@ def add_year_average(year: int, label: str): current_year = datetime.now().year if year_int < 1900 or year_int > current_year: - raise HTTPException(status_code=400, detail=f"comparisonYear must be between 1900 and {current_year}") + raise HTTPException( + status_code=400, + detail=f"comparisonYear must be between 1900 and {current_year}", + ) - # Avoid duplicate if user asked for 1970 both ways - already_added_1970 = isComparingTo1970Average and year_int == 1970 - if not already_added_1970: + if not (isComparingTo1970Average and year_int == 1970): add_year_average(year_int, f"{year_int} Average") return response_data @@ -208,196 +268,87 @@ def add_year_average(year: int, label: str): ) def download_waterlevels_pdf( well_ids: List[int] = Query(..., description="One or more well IDs"), - from_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), - to_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + from_date: date = Query(..., description="Start date in ISO format, 'YYYY-MM-DD'"), + to_date: date = Query(..., description="End date in ISO format, 'YYYY-MM-DD'"), isAveragingAllWells: bool = Query(False), isComparingTo1970Average: bool = Query(False), comparisonYear: Optional[str] = Query(None, pattern=r"^$|^\d{4}$"), db: Session = Depends(get_db), ): - MONITORING_USE_TYPE_ID = 11 - synthetic_id_counter = -1 - - def group_and_average(measurements, group_by_label: str, ra_label: str): - from collections import defaultdict - grouped = defaultdict(list) - for m in measurements: - key = m.timestamp.strftime("%Y-%m" if group_by_label == "month" else "%Y-%m-%d") - grouped[key].append(m.value) - - result = [] - for time_str, values in sorted(grouped.items()): - dt = datetime.strptime(time_str, "%Y-%m" if group_by_label == "month" else "%Y-%m-%d") - avg_value = sum(values) / len(values) - nonlocal synthetic_id_counter - result.append({ - "id": synthetic_id_counter, - "timestamp": dt, - "value": avg_value, - "well_ra_number": ra_label, - }) - synthetic_id_counter -= 1 - return result - - def get_measurements_by_ids(well_ids, start, end): - stmt = ( - select(WellMeasurements) - .options(joinedload(WellMeasurements.submitting_user), joinedload(WellMeasurements.well)) - .join(ObservedPropertyTypeLU) - .where( - and_( - ObservedPropertyTypeLU.name == "Depth to water", - WellMeasurements.well_id.in_(well_ids), - WellMeasurements.timestamp >= start, - WellMeasurements.timestamp <= end, - ) - ) - .order_by(WellMeasurements.well_id, WellMeasurements.timestamp) - ) - return db.scalars(stmt).all() - - # Parse dates - try: - from_date = datetime.strptime(from_month, "%Y-%m").replace(day=1) - to_dt = datetime.strptime(to_month, "%Y-%m") - today = datetime.now() - if to_dt.year == today.year and to_dt.month == today.month: - to_date = today - else: - last_day = calendar.monthrange(to_dt.year, to_dt.month)[1] - to_date = to_dt.replace(day=last_day, hour=23, minute=59, second=59) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM.") - - # treat "" as not provided - comparisonYear = comparisonYear or None - - if not well_ids and not isComparingTo1970Average and not comparisonYear: - raise HTTPException(status_code=400, detail="well_ids is required") - - group_by = "month" if (to_date - from_date).days >= 365 else "day" - results = [] - - # Averaging for selected wells - if isAveragingAllWells and well_ids: - current_measurements = get_measurements_by_ids(well_ids, from_date, to_date) - results.extend(group_and_average(current_measurements, group_by, "Average of wells")) - - # Raw per-well data - if not isAveragingAllWells and well_ids: - raw = get_measurements_by_ids(well_ids, from_date, to_date) - for m in raw: - results.append({ - "id": m.id, - "timestamp": m.timestamp, - "value": m.value, - "well_ra_number": m.well.ra_number if m.well else "Unknown" - }) - - # Helper: add comparison average for any given year (same window rules as 1970) - def add_year_average(year: int, label: str): - if (to_date - from_date).days >= 365: - start = datetime(year, 1, 1) - end = datetime(year, 12, 31, 23, 59, 59) - else: - start = datetime(year, from_date.month, 1) - last_day = calendar.monthrange(year, to_date.month)[1] - end = datetime(year, to_date.month, last_day, 23, 59, 59) + """ + Generate a PDF water-level report between two dates. + Reuses the read_waterlevels() endpoint for data. + """ + + # Reuse the endpoint logic + data = read_waterlevels( + well_ids=well_ids, + from_date=from_date, + to_date=to_date, + isAveragingAllWells=isAveragingAllWells, + isComparingTo1970Average=isComparingTo1970Average, + comparisonYear=comparisonYear, + db=db, + ) - monitoring_ids = [row[0] for row in db.execute( - select(Wells.id).where(Wells.use_type_id == MONITORING_USE_TYPE_ID) - ).all()] - year_measurements = get_measurements_by_ids(monitoring_ids, start, end) - averaged = group_and_average(year_measurements, "month", label) # Always monthly for comparison - results.extend(averaged) + if not data: + raise HTTPException(status_code=404, detail="No water-level data found") - # 1970 Comparison + from_year = from_date.year + shift_years = set() if isComparingTo1970Average: - add_year_average(1970, "1970 Average") - - # Dynamic comparison year + shift_years.add(1970) if comparisonYear: try: - year_int = int(comparisonYear) + shift_years.add(int(comparisonYear)) except ValueError: - raise HTTPException(status_code=400, detail="comparisonYear must be a 4-digit year") - now_year = datetime.now().year - if year_int < 1900 or year_int > now_year: - raise HTTPException(status_code=400, detail=f"comparisonYear must be between 1900 and {now_year}") - - # avoid duplicate series if user chose 1970 in both mechanisms - if not (isComparingTo1970Average and year_int == 1970): - add_year_average(year_int, f"{year_int} Average") - - report_title = "ROSWELL ARTESIAN BASIN" - report_subtext = None - - if isAveragingAllWells: - num_wells = len(well_ids) - well_word = "WELL" if num_wells == 1 else "WELLS" - report_subtext = ( - f"MONTHLY AVERAGE WATER LEVEL WITHIN {num_wells} PVACD RECORDER {well_word}\n" - "AVERAGES TAKEN FROM STEEL TAPE MEASUREMENTS MADE\n" - "ON OR NEAR THE 5TH, 15TH AND 25TH OF EACH MONTH" - ) - - from_year = from_date.year if from_date else None + pass # already validated above 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: last_day = calendar.monthrange(new_year, dt.month)[1] return dt.replace(year=new_year, day=min(dt.day, last_day)) - # Prepare data for table + chart (apply timeshift to comparison series) + # Prepare rows for the table and points for the chart rows = [] data_by_well = defaultdict(list) - # Precompute which series should be shifted (e.g., "1970 Average", "2021 Average") - shift_years = set() - if isComparingTo1970Average: - shift_years.add(1970) - if comparisonYear: - try: - shift_years.add(int(comparisonYear)) - except ValueError: - pass # already validated above; safe guard + for m in data: + # m is a WellMeasurementDTO from read_waterlevels + ts = m.timestamp + val = m.value + ra = m.well["ra_number"] if isinstance(m.well, dict) else m.well.ra_number - for record in results: - original_ts = record["timestamp"] - value = record["value"] - well_label = record["well_ra_number"] - - # Table rows keep original timestamp rows.append({ - "timestamp": original_ts.strftime("%Y-%m-%d %H:%M"), - "depth_to_water": value, - "well_ra_number": well_label, + "timestamp": ts.strftime("%Y-%m-%d %H:%M"), + "depth_to_water": val, + "well_ra_number": ra, }) - chart_ts = original_ts - # Detect labels like "1970 Average" or "2021 Average" and shift to from_year + chart_ts = ts if from_year: - m = re.match(r"^(\d{4}) Average$", well_label) - if m: - yr = int(m.group(1)) + m_match = re.match(r"^(\d{4}) Average$", ra) + if m_match: + yr = int(m_match.group(1)) if yr in shift_years: - chart_ts = shift_year_safe(original_ts, from_year) + chart_ts = shift_year_safe(ts, from_year) - data_by_well[well_label].append((chart_ts, value)) + data_by_well[ra].append((chart_ts, val)) def make_line_chart(data: dict, title: str): if not data: return "" fig = figure(figsize=(10, 6)) ax = fig.add_subplot(111) - for ra, measurements in data.items(): - sorted_measurements = sorted(measurements, key=lambda x: x[0]) - timestamps = [ts for ts, _ in sorted_measurements] - values = [val for _, val in sorted_measurements] - ax.plot(timestamps, values, label=ra, marker='o') + for ra_label, measurements in data.items(): + 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.set_title(title) ax.set_xlabel("Time") ax.set_ylabel("Depth to Water") @@ -410,9 +361,21 @@ def make_line_chart(data: dict, title: str): return b64encode(buf.getvalue()).decode("utf-8") chart_b64 = make_line_chart(data_by_well, "Depth of Water over Time") + + report_title = "ROSWELL ARTESIAN BASIN" + report_subtext = None + if isAveragingAllWells: + num_wells = len(well_ids) + well_word = "WELL" if num_wells == 1 else "WELLS" + report_subtext = ( + f"MONTHLY AVERAGE WATER LEVEL WITHIN {num_wells} PVACD RECORDER {well_word}\n" + "AVERAGES TAKEN FROM STEEL TAPE MEASUREMENTS MADE\n" + "ON OR NEAR THE 5TH, 15TH AND 25TH OF EACH MONTH" + ) + html = templates.get_template("waterlevels_report.html").render( - from_month=from_month, - to_month=to_month, + from_date=from_date, + to_date=to_date, observation_chart=chart_b64, rows=rows, report_title=report_title, @@ -426,7 +389,9 @@ 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" + }, ) diff --git a/api/schemas/well_schemas.py b/api/schemas/well_schemas.py index 226caeee..e19cccd6 100644 --- a/api/schemas/well_schemas.py +++ b/api/schemas/well_schemas.py @@ -132,7 +132,7 @@ class LocationTypeLU(ORMBase): class WellMeasurement(BaseModel): timestamp: datetime - value: float + value: float | None = None submitting_user_id: int unit_id: int @@ -168,7 +168,7 @@ class WellDTO(ORMBase): id: int timestamp: datetime - value: float + value: float | None = None submitting_user: UserDTO well: WellDTO diff --git a/api/templates/chlorides_report.html b/api/templates/chlorides_report.html index 0e77eebf..748c9e01 100644 --- a/api/templates/chlorides_report.html +++ b/api/templates/chlorides_report.html @@ -28,10 +28,11 @@

Chloride Report

- From: {{ from_month or "All Data" }} + From: {{ from_date or "All Data" }}    - To: {{ to_month or "All Data" }} + To: {{ to_date or "All Data" }}

+ diff --git a/api/templates/maintenance_summary.html b/api/templates/maintenance_summary.html index 8bc44978..d732f956 100644 --- a/api/templates/maintenance_summary.html +++ b/api/templates/maintenance_summary.html @@ -1,74 +1,84 @@ - - - - -

Maintenance Summary

-

- From: {{ from_month }}    - To: {{ to_month }} -

- {% if repair_chart %} -
-

Repairs by Meter

- -
- {% endif %} {% if pm_chart %} -
-

Preventative Maintenance by Meter

- -
- {% endif %} + + + + + +

Maintenance Summary

+

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

+ + {% if repair_chart %} +
+

Repairs by Meter

+ +
+ {% endif %} + + {% 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 diff --git a/api/templates/parts_used_report.html b/api/templates/parts_used_report.html index d08ba4c4..dab25a57 100644 --- a/api/templates/parts_used_report.html +++ b/api/templates/parts_used_report.html @@ -1,56 +1,62 @@ - - - - -

Parts Usage Report

-

- From: - {{ from_month }}   - To: - {{ to_month }} -

- - - - - - - - - - - - - {% for row in rows %} - - - - - - - - - {% endfor %} - -
Part #DescriptionPriceQuantityTotalRunning Total
{{ row.part_number }}{{ row.description }}${{ "%.2f"|format(row.price) }}{{ row.quantity }}${{ "%.2f"|format(row.total) }}${{ "%.2f"|format(row.running_total) }}
- - + + + + + + +

Parts Usage Report

+

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

+ + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + {% endfor %} + +
Part #DescriptionPriceQuantityTotalRunning Total
{{ row.part_number }}{{ row.description }}${{ "%.2f"|format(row.price) }}{{ row.quantity }}${{ "%.2f"|format(row.total) }}${{ "%.2f"|format(row.running_total) }}
+ + + \ No newline at end of file diff --git a/api/templates/waterlevels_report.html b/api/templates/waterlevels_report.html index f663315e..3a70d8b0 100644 --- a/api/templates/waterlevels_report.html +++ b/api/templates/waterlevels_report.html @@ -50,8 +50,8 @@

{{ report_title }}

{% endif %}

- From: {{ from_month }}    - To: {{ to_month }} + From: {{ from_date }}    + To: {{ to_date }}

{% if observation_chart %} diff --git a/docker-compose.development.yml b/docker-compose.development.yml index 6f5ec224..3a44c2b6 100644 --- a/docker-compose.development.yml +++ b/docker-compose.development.yml @@ -32,6 +32,7 @@ services: working_dir: /app environment: - GCP_BUCKET_NAME=pvacd + - GCP_WOODPECKER_BUCKET_NAME=pvacd-woodpecker - GCP_BACKUP_PREFIX=pre-prod-db-backups - GCP_PHOTO_PREFIX=pre-prod-meter-activities-photos - BACKUP_RETENTION_DAYS=14 diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 8266cc69..a1edf253 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -32,6 +32,7 @@ services: working_dir: /app environment: - GCP_BUCKET_NAME=pvacd + - GCP_WOODPECKER_BUCKET_NAME=pvacd-woodpecker - GCP_BACKUP_PREFIX=prod-db-backups - GCP_PHOTO_PREFIX=prod-meter-activities-photos - BACKUP_RETENTION_DAYS=90 diff --git a/frontend/src/components/ModalBackgroundBox.tsx b/frontend/src/components/ModalBackgroundBox.tsx new file mode 100644 index 00000000..23306cdf --- /dev/null +++ b/frontend/src/components/ModalBackgroundBox.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { Box, BoxProps } from "@mui/material"; + +export const ModalBackgroundBox: React.FC = ({ + children, + sx, + ...rest +}) => { + return ( + + {children} + + ); +}; diff --git a/frontend/src/components/NewMeasurementModal.tsx b/frontend/src/components/Modals/MonitoredWell/Create.tsx similarity index 80% rename from frontend/src/components/NewMeasurementModal.tsx rename to frontend/src/components/Modals/MonitoredWell/Create.tsx index 5378ec7b..2cda03ab 100644 --- a/frontend/src/components/NewMeasurementModal.tsx +++ b/frontend/src/components/Modals/MonitoredWell/Create.tsx @@ -1,5 +1,4 @@ import { - Box, Modal, TextField, Button, @@ -8,19 +7,24 @@ import { FormControl, InputLabel, Grid, + Typography, } from "@mui/material"; import { useState } from "react"; import { useAuthUser } from "react-auth-kit"; -import { NewWellMeasurement, SecurityScope } from "../interfaces.js"; +import { + NewWellMeasurement, + SecurityScope, +} from "../../../interfaces.js"; import dayjs, { Dayjs } from "dayjs"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; dayjs.extend(utc); dayjs.extend(timezone); import { DatePicker, TimePicker } from "@mui/x-date-pickers"; -import { useGetUserList } from "../service/ApiServiceNew"; +import { useGetUserList } from "../../../service/ApiServiceNew"; +import { ModalBackgroundBox } from "../../ModalBackgroundBox.js"; -export function NewMeasurementModal({ +export function CreateModal({ isNewMeasurementModalOpen, handleCloseNewMeasurementModal, handleSubmitNewMeasurement, @@ -97,26 +101,15 @@ export function NewMeasurementModal({ open={isNewMeasurementModalOpen} onClose={handleCloseNewMeasurementModal} > - - -

Record a New Measurement

- + + + + Create New Measurement + + - + - + - + - + + + + + + + ); +} diff --git a/frontend/src/components/Modals/MonitoredWell/index.ts b/frontend/src/components/Modals/MonitoredWell/index.ts new file mode 100644 index 00000000..72a26f64 --- /dev/null +++ b/frontend/src/components/Modals/MonitoredWell/index.ts @@ -0,0 +1,2 @@ +export * from "./Create" +export * from "./Update" diff --git a/frontend/src/components/Modals/Region/Create.tsx b/frontend/src/components/Modals/Region/Create.tsx new file mode 100644 index 00000000..6208e47a --- /dev/null +++ b/frontend/src/components/Modals/Region/Create.tsx @@ -0,0 +1,254 @@ +import { + Modal, + TextField, + Button, + MenuItem, + Select, + FormControl, + InputLabel, + Grid, + Typography, + FormControlLabel, + Checkbox, +} from "@mui/material"; +import { useState } from "react"; +import { useAuthUser } from "react-auth-kit"; +import { + MonitoredWell, + NewRegionMeasurement, + SecurityScope, +} from "../../../interfaces.js"; +import dayjs, { Dayjs } from "dayjs"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; +dayjs.extend(utc); +dayjs.extend(timezone); +import { DatePicker, TimePicker } from "@mui/x-date-pickers"; +import { RadioButtonUnchecked, TaskAlt } from "@mui/icons-material"; +import { useGetUserList } from "../../../service/ApiServiceNew"; +import { useQuery } from "react-query"; +import { useFetchWithAuth } from "../../../hooks/useFetchWithAuth.js"; +import { ModalBackgroundBox } from "./../../"; + +export const CreateModal = ({ + region_id, //Used to filter wells + isNewMeasurementModalOpen, + handleCloseNewMeasurementModal, + handleSubmitNewMeasurement, +}: { + region_id: number; //Used to filter wells + isNewMeasurementModalOpen: boolean; + handleCloseNewMeasurementModal: () => void; + handleSubmitNewMeasurement: (newMeasurement: NewRegionMeasurement) => void; +}) => { + const authUser = useAuthUser(); + const hasAdminScope = authUser() + ?.user_role.security_scopes.map( + (scope: SecurityScope) => scope.scope_string, + ) + .includes("admin"); + + const fetchWithAuth = useFetchWithAuth(); + const { data: wells, isLoading: isLoadingWells } = useQuery< + { items: MonitoredWell[] }, + Error, + MonitoredWell[] + >({ + queryKey: ["wells", "has_chloride_groups", region_id], + queryFn: () => + fetchWithAuth({ + method: "GET", + route: "/wells", + params: { + sort_by: "ra_number", + sort_direction: "asc", + has_chloride_group: true, + chloride_group_id: region_id, + limit: 100, + }, + }), + enabled: isNewMeasurementModalOpen, + select: (res) => res.items, + }); + + const userList = useGetUserList(); + const [value, setValue] = useState(null); + const [notSampled, setNotSampled] = useState(false); + const [selectedUserID, setSelectedUserID] = useState(""); + const [selectedWellID, setSelectedWellID] = useState(""); + const [date, setDate] = useState(dayjs.utc()); + const [time, setTime] = useState(dayjs.utc()); + + function onMeasurementSubmitted() { + // default fallback: now + const selectedDate = date ?? dayjs(); + const selectedTime = time ?? dayjs(); + + // merge date + time into one object + const combinedDateTime = selectedDate + .hour(selectedTime.hour()) + .minute(selectedTime.minute()) + .second(selectedTime.second()); + + handleSubmitNewMeasurement({ + region_id: 0, // Set by parent + well_id: selectedWellID as number, + timestamp: combinedDateTime.toISOString(), + value: value as number, + submitting_user_id: selectedUserID as number, + }); + } + + const UserSelection = () => { + if (hasAdminScope) { + return ( + + User + + + ); + } else { + setSelectedUserID(authUser()?.id); + return null; + } + }; + + const WellSelection = ({ region_id }: { region_id: number }) => { + return ( + + Well + + + ); + }; + + return ( + + + + + Create New Measurement + + + + + + + + + + + + } + checkedIcon={} + checked={notSampled} + onChange={(e) => { + const checked = e.target.checked; + setNotSampled(checked) + + if (checked) { + setValue(null); + } + }} + /> + } + label="Well was visited but NOT SAMPLED" + labelPlacement="end" + /> + + + { + const newValue = event.target.value; + setValue(newValue === "" ? null : Number(newValue)); + }} + /> + + + + + + + + + + + ); +}; diff --git a/frontend/src/components/Modals/Region/Update.tsx b/frontend/src/components/Modals/Region/Update.tsx new file mode 100644 index 00000000..1c5e4dcb --- /dev/null +++ b/frontend/src/components/Modals/Region/Update.tsx @@ -0,0 +1,255 @@ +import { useEffect, useState } from "react"; +import { + Modal, + TextField, + Button, + MenuItem, + Select, + FormControl, + InputLabel, + Grid, + Typography, + FormControlLabel, + Checkbox, +} from "@mui/material"; +import { + MonitoredWell, + PatchRegionMeasurement, +} from "../../../interfaces.js"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; +import dayjs from "dayjs"; +dayjs.extend(utc); +dayjs.extend(timezone); +import { DatePicker, TimePicker } from "@mui/x-date-pickers"; +import { RadioButtonUnchecked, TaskAlt } from "@mui/icons-material"; +import { useGetUserList } from "../../../service/ApiServiceNew"; +import { useQuery } from "react-query"; +import { useFetchWithAuth } from "../../../hooks/useFetchWithAuth.js"; +import { ModalBackgroundBox } from "./../../"; + + +export const UpdateModal = ({ + region_id, //Used to filter wells + isMeasurementModalOpen, + handleCloseMeasurementModal, + measurement, + onUpdateMeasurement, + onSubmitUpdate, + onDeleteMeasurement, +}: { + region_id: number; //Used to filter wells + isMeasurementModalOpen: boolean; + handleCloseMeasurementModal: () => void; + measurement: PatchRegionMeasurement; + onUpdateMeasurement: (value: Partial) => void; + onSubmitUpdate: () => void; + onDeleteMeasurement: () => void; +}) => { + const userList = useGetUserList(); + const fetchWithAuth = useFetchWithAuth(); + + const [notSampled, setNotSampled] = useState( + measurement.value === undefined || measurement.value === null + ); + const [previousValue, setPreviousValue] = useState(null); + + const { data: wells, isLoading: isLoadingWells } = useQuery< + { items: MonitoredWell[] }, + Error, + MonitoredWell[] + >({ + queryKey: ["wells", "has_chloride_groups", region_id], + queryFn: () => + fetchWithAuth({ + method: "GET", + route: "/wells", + params: { + sort_by: "ra_number", + sort_direction: "asc", + has_chloride_group: true, + chloride_group_id: region_id, + limit: 100, + }, + }), + enabled: isMeasurementModalOpen, + select: (res) => res.items, + }); + + const handleToggleNotSampled = (checked: boolean) => { + setNotSampled(checked); + + if (checked) { + // Store previous numeric value and clear backend value + setPreviousValue(measurement.value ?? null); + onUpdateMeasurement({ value: null }); + } else { + // Restore previous numeric value when toggled back + if (previousValue !== null) { + onUpdateMeasurement({ value: previousValue }); + } + } + }; + + useEffect(() => { + setNotSampled(measurement.value == null); + }, [measurement.value]); + + return ( + + + + + Update Measurement + + + + User + + + + + + dateval ? onUpdateMeasurement({ timestamp: dateval }) : null + } + slotProps={{ + textField: { size: "small", fullWidth: true, required: true }, + }} + /> + + + + dateval ? onUpdateMeasurement({ timestamp: dateval }) : null + } + /> + + + } + checkedIcon={} + checked={notSampled} + onChange={(e) => handleToggleNotSampled(e.target.checked)} + /> + } + label="Well was visited but NOT SAMPLED" + labelPlacement="end" + /> + + + + onUpdateMeasurement({ + value: + event.target.value === "" + ? null + : Number(event.target.value), + }) + } + /> + + + + Well + + + + + + + + + + + ); +} diff --git a/frontend/src/components/Modals/Region/index.ts b/frontend/src/components/Modals/Region/index.ts new file mode 100644 index 00000000..72a26f64 --- /dev/null +++ b/frontend/src/components/Modals/Region/index.ts @@ -0,0 +1,2 @@ +export * from "./Create" +export * from "./Update" diff --git a/frontend/src/components/Modals/index.ts b/frontend/src/components/Modals/index.ts new file mode 100644 index 00000000..0e9fb69b --- /dev/null +++ b/frontend/src/components/Modals/index.ts @@ -0,0 +1 @@ +export * from './Region' diff --git a/frontend/src/components/RegionMeasurementModals.tsx b/frontend/src/components/RegionMeasurementModals.tsx deleted file mode 100644 index b85afb29..00000000 --- a/frontend/src/components/RegionMeasurementModals.tsx +++ /dev/null @@ -1,424 +0,0 @@ -import { - Box, - Modal, - TextField, - Button, - MenuItem, - Select, - FormControl, - InputLabel, - Grid, -} from "@mui/material"; -import { useState } from "react"; -import { useAuthUser } from "react-auth-kit"; -import { - MonitoredWell, - NewRegionMeasurement, - PatchRegionMeasurement, - SecurityScope, -} from "../interfaces.js"; -import dayjs, { Dayjs } from "dayjs"; -import utc from "dayjs/plugin/utc"; -import timezone from "dayjs/plugin/timezone"; -dayjs.extend(utc); -dayjs.extend(timezone); -import { DatePicker, TimePicker } from "@mui/x-date-pickers"; -import { useGetUserList } from "../service/ApiServiceNew"; -import { useQuery } from "react-query"; -import { useFetchWithAuth } from "../hooks/useFetchWithAuth.js"; - -export const NewMeasurementModal = ({ - region_id, //Used to filter wells - isNewMeasurementModalOpen, - handleCloseNewMeasurementModal, - handleSubmitNewMeasurement, -}: { - region_id: number; //Used to filter wells - isNewMeasurementModalOpen: boolean; - handleCloseNewMeasurementModal: () => void; - handleSubmitNewMeasurement: (newMeasurement: NewRegionMeasurement) => void; -}) => { - const authUser = useAuthUser(); - const hasAdminScope = authUser() - ?.user_role.security_scopes.map( - (scope: SecurityScope) => scope.scope_string, - ) - .includes("admin"); - - const fetchWithAuth = useFetchWithAuth(); - const { data: wells, isLoading: isLoadingWells } = useQuery< - { items: MonitoredWell[] }, - Error, - MonitoredWell[] - >({ - queryKey: ["wells", "has_chloride_groups", region_id], - queryFn: () => - fetchWithAuth({ - method: "GET", - route: "/wells", - params: { - sort_by: "ra_number", - sort_direction: "asc", - has_chloride_group: true, - chloride_group_id: region_id, - limit: 100, - }, - }), - enabled: isNewMeasurementModalOpen, - select: (res) => res.items, - }); - - const userList = useGetUserList(); - const [value, setValue] = useState(null); - const [selectedUserID, setSelectedUserID] = useState(""); - const [selectedWellID, setSelectedWellID] = useState(""); - const [date, setDate] = useState(dayjs.utc()); - const [time, setTime] = useState(dayjs.utc()); - - function onMeasurementSubmitted() { - // default fallback: now - const selectedDate = date ?? dayjs(); - const selectedTime = time ?? dayjs(); - - // merge date + time into one object - const combinedDateTime = selectedDate - .hour(selectedTime.hour()) - .minute(selectedTime.minute()) - .second(selectedTime.second()); - - handleSubmitNewMeasurement({ - region_id: 0, // Set by parent - well_id: selectedWellID as number, - timestamp: combinedDateTime.toISOString(), - value: value as number, - submitting_user_id: selectedUserID as number, - }); - } - - const UserSelection = () => { - if (hasAdminScope) { - return ( - - User - - - ); - } else { - setSelectedUserID(authUser()?.id); - return null; - } - }; - - const WellSelection = ({ region_id }: { region_id: number }) => { - return ( - - Well - - - ); - }; - - return ( - - - -

Record a New Measurement

- - - - - - - - - - - - setValue(event.target.value as unknown as number) - } - /> - - - - - - - -
-
-
- ); -}; - -export const UpdateMeasurementModal = ({ - region_id, //Used to filter wells - isMeasurementModalOpen, - handleCloseMeasurementModal, - measurement, - onUpdateMeasurement, - onSubmitUpdate, - onDeleteMeasurement, -}: { - region_id: number; //Used to filter wells - isMeasurementModalOpen: boolean; - handleCloseMeasurementModal: () => void; - measurement: PatchRegionMeasurement; - onUpdateMeasurement: (value: Partial) => void; - onSubmitUpdate: () => void; - onDeleteMeasurement: () => void; -}) => { - const userList = useGetUserList(); - const fetchWithAuth = useFetchWithAuth(); - const { data: wells, isLoading: isLoadingWells } = useQuery< - { items: MonitoredWell[] }, - Error, - MonitoredWell[] - >({ - queryKey: ["wells", "has_chloride_groups", region_id], - queryFn: () => - fetchWithAuth({ - method: "GET", - route: "/wells", - params: { - sort_by: "ra_number", - sort_direction: "asc", - has_chloride_group: true, - chloride_group_id: region_id, - limit: 100, - }, - }), - enabled: isMeasurementModalOpen, - select: (res) => res.items, - }); - - return ( - - - -

Update Measurement

- - - User - - - - - - dateval ? onUpdateMeasurement({ timestamp: dateval }) : null - } - slotProps={{ - textField: { size: "small", fullWidth: true, required: true }, - }} - /> - - - - dateval ? onUpdateMeasurement({ timestamp: dateval }) : null - } - /> - - - - onUpdateMeasurement({ - value: event.target.value as unknown as number, - }) - } - /> - - - - Well - - - - - - - -
-
-
- ); -}; diff --git a/frontend/src/components/WellMeasurementModals.tsx b/frontend/src/components/WellMeasurementModals.tsx deleted file mode 100644 index e8440226..00000000 --- a/frontend/src/components/WellMeasurementModals.tsx +++ /dev/null @@ -1,298 +0,0 @@ -import { - Box, - Modal, - TextField, - Button, - MenuItem, - Select, - FormControl, - InputLabel, - Grid, -} from "@mui/material"; -import { useState } from "react"; -import { useAuthUser } from "react-auth-kit"; -import { - NewWellMeasurement, - PatchWellMeasurement, - SecurityScope, -} from "../interfaces.js"; -import dayjs, { Dayjs } from "dayjs"; -import utc from "dayjs/plugin/utc"; -import timezone from "dayjs/plugin/timezone"; -dayjs.extend(utc); -dayjs.extend(timezone); -import { DatePicker, TimePicker } from "@mui/x-date-pickers"; -import { useGetUserList } from "../service/ApiServiceNew"; - -export function NewMeasurementModal({ - isNewMeasurementModalOpen, - handleCloseNewMeasurementModal, - handleSubmitNewMeasurement, -}: { - isNewMeasurementModalOpen: boolean; - handleCloseNewMeasurementModal: () => void; - handleSubmitNewMeasurement: (newMeasurement: NewWellMeasurement) => void; -}) { - const authUser = useAuthUser(); - const hasAdminScope = authUser() - ?.user_role.security_scopes.map( - (scope: SecurityScope) => scope.scope_string, - ) - .includes("admin"); - - const userList = useGetUserList(); - const [value, setValue] = useState(null); - const [selectedUserID, setSelectedUserID] = useState(""); - const [date, setDate] = useState(dayjs.utc()); - const [time, setTime] = useState(dayjs.utc()); - - // Sends user entered information to the parent through callback - function onMeasurementSubmitted() { - // default fallback: now - const selectedDate = date ?? dayjs(); - const selectedTime = time ?? dayjs(); - - // merge date + time into one object - const combinedDateTime = selectedDate - .hour(selectedTime.hour()) - .minute(selectedTime.minute()) - .second(selectedTime.second()); - - handleSubmitNewMeasurement({ - timestamp: combinedDateTime.toISOString(), - value: value as number, - submitting_user_id: selectedUserID as number, - well_id: -1, // Set by parent - }); - } - - function UserSelection() { - if (hasAdminScope) { - return ( - - User - - - ); - } else { - setSelectedUserID(authUser()?.id); - return null; - } - } - - return ( - - - -

Record a New Measurement

- - - - - - - - - - - - setValue(event.target.value as unknown as number) - } - /> - - - - -
-
-
- ); -} - -interface UpdateMeasurementModalProps { - isMeasurementModalOpen: boolean; - handleCloseMeasurementModal: () => void; - measurement: PatchWellMeasurement; - onUpdateMeasurement: (value: Partial) => void; - onSubmitUpdate: () => void; - onDeleteMeasurement: () => void; -} - -export function UpdateMeasurementModal({ - isMeasurementModalOpen, - handleCloseMeasurementModal, - measurement, - onUpdateMeasurement, - onSubmitUpdate, - onDeleteMeasurement, -}: UpdateMeasurementModalProps) { - const userList = useGetUserList(); - - return ( - - - -

Update Measurement

- - - User - - - - - - dateval ? onUpdateMeasurement({ timestamp: dateval }) : null - } - slotProps={{ - textField: { size: "small", fullWidth: true, required: true }, - }} - /> - - - - dateval ? onUpdateMeasurement({ timestamp: dateval }) : null - } - /> - - - - onUpdateMeasurement({ - value: event.target.value as unknown as number, - }) - } - /> - - - - - -
-
-
- ); -} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index dcb5df95..10191177 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -1,9 +1,9 @@ export * from './BackgroundBox' +export * from './ModalBackgroundBox' export * from './TristateToggle' export * from './TopbarUserButton' export * from './ChipSelect' export * from './MergeWellModal' -export * from './RegionMeasurementModals' export * from './UserSelection' export * from './CustomCardHeader' export * from './MeterRegisterSelect' @@ -27,3 +27,4 @@ export * from './WellMapLegend' export * from './ImagePreviewGrid' export * from './ImageUploadWithPreview' export * from './ImageDialog' +export * from './Modals' diff --git a/frontend/src/interfaces.d.ts b/frontend/src/interfaces.d.ts index de06ea97..482b4d2a 100644 --- a/frontend/src/interfaces.d.ts +++ b/frontend/src/interfaces.d.ts @@ -573,7 +573,7 @@ export interface PatchWellMeasurement { export interface NewRegionMeasurement { region_id: number timestamp: string - value: number + value?: number | null submitting_user_id: number well_id: number } @@ -583,7 +583,7 @@ export interface PatchRegionMeasurement { submitting_user_id: number well_id: number timestamp: dayjs.Dayjs - value: number + value?: number | null } export interface CreateUser { diff --git a/frontend/src/utils/EmptyToNull.ts b/frontend/src/utils/EmptyToNull.ts new file mode 100644 index 00000000..4d3b9bc9 --- /dev/null +++ b/frontend/src/utils/EmptyToNull.ts @@ -0,0 +1,3 @@ +export const emptyToNull = (val: any) => (val === "" ? null : val); + + diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 48b13c8a..cf97aab3 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -1,4 +1,5 @@ export * from "./DateUtils" +export * from "./EmptyToNull" export * from "./HttpUtils" export * from "./GetMeterMarkerColor" export * from "./GetRoleColor" diff --git a/frontend/src/views/Chlorides/ChloridesTable.tsx b/frontend/src/views/Chlorides/ChloridesTable.tsx index 1aebcf72..f16aeef7 100644 --- a/frontend/src/views/Chlorides/ChloridesTable.tsx +++ b/frontend/src/views/Chlorides/ChloridesTable.tsx @@ -56,7 +56,13 @@ export const ChloridesTable = ({ dayjs.utc(value).tz("America/Denver").format("MM/DD/YYYY hh:mm A"), type: "dateTime", }, - { field: "value", headerName: "Chlorides (ppm)", width: 175 }, + { + field: "value", + headerName: "Chlorides (ppm)", + width: 175, + valueFormatter: (value) => + value == null ? "NOT SAMPLED" : value, + }, { field: "well", headerName: "Well", diff --git a/frontend/src/views/Chlorides/ChloridesView.tsx b/frontend/src/views/Chlorides/ChloridesView.tsx index 2f5fdc95..0b4d4e3b 100644 --- a/frontend/src/views/Chlorides/ChloridesView.tsx +++ b/frontend/src/views/Chlorides/ChloridesView.tsx @@ -13,12 +13,13 @@ import { } from "@mui/material"; import { useMutation, useQuery } from "react-query"; import { useAuthUser } from "react-auth-kit"; +import { useSnackbar } from "notistack"; import { ChloridesTable } from "./ChloridesTable"; import { ChloridesPlot } from "./ChloridesPlot"; import { - NewMeasurementModal, - UpdateMeasurementModal, -} from "../../components/RegionMeasurementModals"; + CreateModal, + UpdateModal, +} from "../../components/Modals/Region"; import { NewRegionMeasurement, PatchRegionMeasurement, @@ -30,8 +31,10 @@ import { useFetchWithAuth } from "../../hooks"; import { Science } from "@mui/icons-material"; import { BackgroundBox } from "../../components/BackgroundBox"; import { CustomCardHeader } from "../../components/CustomCardHeader"; +import { emptyToNull } from "../../utils"; export const ChloridesView = () => { + const { enqueueSnackbar } = useSnackbar(); const fetchWithAuth = useFetchWithAuth(); const selectedRegionId = useId(); const [regionId, setregionId] = useState(); @@ -94,13 +97,21 @@ export const ChloridesView = () => { route: "/chlorides", body: { timestamp: body.timestamp, - value: body.value, + value: emptyToNull(body.value), submitting_user_id: body.submitting_user_id, chloride_group_id: body.region_id, unit_id: milligramPerLiterUnitId, well_id: body.well_id, }, }), + onSuccess: () => { + enqueueSnackbar("Chloride measurement created successfully", { variant: "success" }); + }, + onError: (err: any) => { + enqueueSnackbar(`Failed to create chloride measurement: ${err.message ?? "Unknown error"}`, { + variant: "error", + }); + }, }); const { mutateAsync: updateChlorideLevel } = useMutation({ @@ -112,13 +123,21 @@ export const ChloridesView = () => { body: { id: body.levelmeasurement_id, timestamp: body.timestamp, - value: body.value, + value: emptyToNull(body.value), submitting_user_id: body.submitting_user_id, chloride_group_id: regionId, unit_id: milligramPerLiterUnitId, well_id: body.well_id, }, }), + onSuccess: () => { + enqueueSnackbar("Chloride measurement updated successfully", { variant: "success" }); + }, + onError: (err: any) => { + enqueueSnackbar(`Failed to update chloride measurement: ${err.message ?? "Unknown error"}`, { + variant: "error", + }); + }, }); const { mutateAsync: deleteChlorideLevel } = useMutation({ @@ -129,6 +148,14 @@ export const ChloridesView = () => { route: "/chlorides", params: { chloride_measurement_id: levelmeasurement_id }, }), + onSuccess: () => { + enqueueSnackbar("Chloride measurement deleted successfully", { variant: "success" }); + }, + onError: (err: any) => { + enqueueSnackbar(`Failed to delete chloride measurement: ${err.message ?? "Unknown error"}`, { + variant: "error", + }); + }, }); const error = errorRegions || errorManual; @@ -260,13 +287,13 @@ export const ChloridesView = () => {
{authUser() && ( <> - setIsNewModalOpen(false)} handleSubmitNewMeasurement={handleSubmitNewMeasurement} /> - setIsUpdateModalOpen(false)} diff --git a/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx b/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx index 0d612566..70a108d3 100644 --- a/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx +++ b/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx @@ -17,7 +17,6 @@ import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; import { CustomCardHeader, ImageDialog, ImagePreviewGrid } from "../../../components"; import { ImageOutlined } from "@mui/icons-material"; -import { API_URL } from "../../../config"; dayjs.extend(utc); dayjs.extend(timezone); @@ -36,7 +35,7 @@ export const MeterHistory = ({ const photos = useMemo(() => { if (selectedHistoryItem?.history_type === MeterHistoryType.Activity) { return ( - selectedHistoryItem.photos?.map((p: any) => `${API_URL}${p.url}`) ?? [] + selectedHistoryItem.photos?.map((p: any) => p.url) ?? [] ); } return []; diff --git a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx b/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx index 4e9038ca..7b7e9a29 100644 --- a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx +++ b/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx @@ -18,9 +18,9 @@ import { useAuthUser } from "react-auth-kit"; import { MonitoringWellsTable } from "./MonitoringWellsTable"; import { MonitoringWellsPlot } from "./MonitoringWellsPlot"; import { - NewMeasurementModal, - UpdateMeasurementModal, -} from "../../components/WellMeasurementModals"; + CreateModal, + UpdateModal, +} from "../../components/Modals/MonitoredWell"; import { NewWellMeasurement, PatchWellMeasurement, @@ -289,12 +289,12 @@ export const MonitoringWellsView = () => {
{authUser() && ( <> - setIsNewModalOpen(false)} handleSubmitNewMeasurement={handleSubmitNewMeasurement} /> - setIsUpdateModalOpen(false)} measurement={selectedMeasurement} diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index df061442..eeff2111 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -50,8 +50,8 @@ const schema = yup.object().shape({ }); const defaultSchema = { - from: dayjs(), - to: dayjs(), + from: dayjs().startOf('month'), + to: dayjs().endOf('month'), }; interface iMinMaxAvgMedCount { @@ -85,8 +85,8 @@ export const ChloridesReportView = () => { queryKey: ["Chlorides", "Reports", from, to], queryFn: async () => { const searchParams = new URLSearchParams({ - from_month: from?.format("YYYY-MM"), - to_month: to?.format("YYYY-MM"), + from_date: from?.format("YYYY-MM-DD"), + to_date: to?.format("YYYY-MM-DD"), }); return fetchWithAuth({ @@ -106,8 +106,8 @@ export const ChloridesReportView = () => { to: Dayjs; }) => { const params = new URLSearchParams({ - from_month: from.format("YYYY-MM"), - to_month: to.format("YYYY-MM"), + from_date: from?.format("YYYY-MM-DD"), + to_date: to?.format("YYYY-MM-DD"), }); const response = await fetch( @@ -200,9 +200,9 @@ export const ChloridesReportView = () => { label="From" control={control} name="from" - views={["year", "month"]} + views={["year", "month", "day"]} openTo="year" - format="YYYY MMMM" + format="YYYY MMMM DD" /> @@ -212,9 +212,9 @@ export const ChloridesReportView = () => { label="To" control={control} name="to" - views={["year", "month"]} + views={["year", "month", "day"]} openTo="year" - format="YYYY MMMM" + format="YYYY MMMM DD" /> diff --git a/frontend/src/views/Reports/Maintenance/index.tsx b/frontend/src/views/Reports/Maintenance/index.tsx index 3fa4bcbc..d820079f 100644 --- a/frontend/src/views/Reports/Maintenance/index.tsx +++ b/frontend/src/views/Reports/Maintenance/index.tsx @@ -69,8 +69,8 @@ const schema = yup.object().shape({ }); const defaultSchema = { - from: dayjs(), - to: dayjs(), + from: dayjs().startOf('month'), + to: dayjs().endOf('month'), techicians: [{ ...allTechniciansOption }], trss: "", }; @@ -115,16 +115,16 @@ export const MaintenanceReportView = () => { queryKey: [ "maintenance", { - from: from?.format("YYYY-MM"), - to: to?.format("YYYY-MM"), + from: from?.format("YYYY-MM-DD"), + to: to?.format("YYYY-MM-DD"), trss: trss ?? "", technicians: technicians?.map((t) => t.id) ?? [], }, ], queryFn: async () => { const queryParams = new URLSearchParams(); - queryParams.set("from_month", from?.format("YYYY-MM")); - queryParams.set("to_month", to?.format("YYYY-MM")); + queryParams.set("from_date", from?.format("YYYY-MM-DD")); + queryParams.set("to_date", to?.format("YYYY-MM-DD")); queryParams.set("trss", trss ?? ""); technicians @@ -229,9 +229,9 @@ export const MaintenanceReportView = () => { technicians: number[]; }) => { const params = new URLSearchParams({ - from_month: from.format("YYYY-MM"), - to_month: to.format("YYYY-MM"), - trss: "", // optional — if unused you can remove it on both ends + from_date: from.format("YYYY-MM-DD"), + to_date: to.format("YYYY-MM-DD"), + trss: trss ?? "", }); technicians.forEach((id) => params.append("technicians", id.toString())); @@ -309,9 +309,9 @@ export const MaintenanceReportView = () => { label="From" control={control} name="from" - views={["year", "month"]} + views={["year", "month", "day"]} openTo="year" - format="YYYY MMMM" + format="YYYY MMMM DD" /> @@ -321,9 +321,9 @@ export const MaintenanceReportView = () => { label="To" control={control} name="to" - views={["year", "month"]} + views={["year", "month", "day"]} openTo="year" - format="YYYY MMMM" + format="YYYY MMMM DD" /> diff --git a/frontend/src/views/Reports/MonitoringWells/index.tsx b/frontend/src/views/Reports/MonitoringWells/index.tsx index 445223c7..fc93959b 100644 --- a/frontend/src/views/Reports/MonitoringWells/index.tsx +++ b/frontend/src/views/Reports/MonitoringWells/index.tsx @@ -76,8 +76,8 @@ const schema = yup.object().shape({ }); const defaultSchema = { - from: dayjs(), - to: dayjs(), + from: dayjs().startOf('month'), + to: dayjs().endOf('month'), wells: [], isAveragingAllWells: false, isComparingTo1970Average: false, @@ -165,8 +165,8 @@ export const MonitoringWellsReportView = () => { ], queryFn: () => { const searchParams = new URLSearchParams({ - from_month: from?.format("YYYY-MM"), - to_month: to?.format("YYYY-MM"), + from_date: from?.format("YYYY-MM-DD"), + to_date: to?.format("YYYY-MM-DD"), isAveragingAllWells: isAveragingAllWells.toString(), isComparingTo1970Average: isComparingTo1970Average.toString(), comparisonYear: comparisonYear ? comparisonYear.toString() : "" @@ -294,8 +294,8 @@ export const MonitoringWellsReportView = () => { comparisonYear: string; }) => { const params = new URLSearchParams({ - from_month: from.format("YYYY-MM"), - to_month: to.format("YYYY-MM"), + from_date: from?.format("YYYY-MM-DD"), + to_date: to?.format("YYYY-MM-DD"), isAveragingAllWells: isAveragingAllWells.toString(), isComparingTo1970Average: isComparingTo1970Average.toString(), comparisonYear @@ -384,9 +384,9 @@ export const MonitoringWellsReportView = () => { control={control} size="small" name="from" - views={["year", "month"]} + views={["year", "month", "day"]} openTo="year" - format="YYYY MMMM" + format="YYYY MMMM DD" /> @@ -396,9 +396,9 @@ export const MonitoringWellsReportView = () => { control={control} size="small" name="to" - views={["year", "month"]} + views={["year", "month", "day"]} openTo="year" - format="YYYY MMMM" + format="YYYY MMMM DD" /> diff --git a/frontend/src/views/Reports/PartsUsed/index.tsx b/frontend/src/views/Reports/PartsUsed/index.tsx index 7fff7868..d8b1afdf 100644 --- a/frontend/src/views/Reports/PartsUsed/index.tsx +++ b/frontend/src/views/Reports/PartsUsed/index.tsx @@ -91,8 +91,8 @@ const schema = yup.object().shape({ }); const defaultSchema = { - from: dayjs(), - to: dayjs(), + from: dayjs().startOf('month'), + to: dayjs().endOf('month'), part_types: [], parts: [], in_use: true @@ -154,8 +154,8 @@ export const PartsUsedReportView = () => { queryKey: ["Inventory", "report", "partsused", from, to, selectedPartIds], queryFn: async () => { const searchParams = new URLSearchParams({ - from_month: from?.format("YYYY-MM"), - to_month: to?.format("YYYY-MM"), + from_date: from?.format("YYYY-MM-DD"), + to_date: to?.format("YYYY-MM-DD"), }); selectedPartIds.forEach((id: number) => { @@ -231,8 +231,8 @@ export const PartsUsedReportView = () => { parts: number[]; }) => { const params = new URLSearchParams({ - from_month: from.format("YYYY-MM"), - to_month: to.format("YYYY-MM"), + from_date: from.format("YYYY-MM-DD"), + to_date: to.format("YYYY-MM-DD"), }); parts.forEach((id) => params.append("parts", id.toString())); @@ -316,9 +316,9 @@ export const PartsUsedReportView = () => { label="From" control={control} name="from" - views={["year", "month"]} + views={["year", "month", "day"]} openTo="year" - format="YYYY MMMM" + format="YYYY MMMM DD" /> @@ -328,9 +328,9 @@ export const PartsUsedReportView = () => { label="To" control={control} name="to" - views={["year", "month"]} + views={["year", "month", "day"]} openTo="year" - format="YYYY MMMM" + format="YYYY MMMM DD" /> diff --git a/frontend/src/views/WellManagement/WellSelectionTable.tsx b/frontend/src/views/WellManagement/WellSelectionTable.tsx index 570fed78..b91cc863 100644 --- a/frontend/src/views/WellManagement/WellSelectionTable.tsx +++ b/frontend/src/views/WellManagement/WellSelectionTable.tsx @@ -92,7 +92,7 @@ export default function WellSelectionTable({ const meters = params.value as Well["meters"]; const links = meters.map((meter, index) => ( - + {meter.serial_number} {index < params.value.length - 1 ? ", " : ""}