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 @@
- From: {{ from_month or "All Data" }} + From: {{ from_date or "All Data" }} - To: {{ to_month or "All Data" }} + To: {{ to_date or "All Data" }}
+| Date / Time | +Technician | +Meter | +Number of Repairs | +Number of Preventative Maintenances | +
|---|---|---|---|---|
| {{ row.date_time }} | +{{ row.technician }} | +{{ row.meter }} | +{{ row.number_of_repairs }} | +{{ row.number_of_pms }} | +
- From: - {{ from_month }} - To: - {{ to_month }} -
-| Part # | -Description | -Price | -Quantity | -Total | -Running Total | -
|---|---|---|---|---|---|
| {{ row.part_number }} | -{{ row.description }} | -${{ "%.2f"|format(row.price) }} | -{{ row.quantity }} | -${{ "%.2f"|format(row.total) }} | -${{ "%.2f"|format(row.running_total) }} | -
+ From: + {{ from_date }} + To: + {{ to_date }} +
+| Part # | +Description | +Price | +Quantity | +Total | +Running Total | +
|---|---|---|---|---|---|
| {{ row.part_number }} | +{{ row.description }} | +${{ "%.2f"|format(row.price) }} | +{{ row.quantity }} | +${{ "%.2f"|format(row.total) }} | +${{ "%.2f"|format(row.running_total) }} | +
- 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