Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
04acd64
[Topbar] Improved Menu UI
TylerAdamMartinez Sep 4, 2025
0d78686
[NavLink] Mv to mui implementation of navlinks
TylerAdamMartinez Sep 4, 2025
2ded745
[sidenav] Rm unneeded search params
TylerAdamMartinez Sep 5, 2025
f2519d0
[/WorkOrders] Refactor work order components
TylerAdamMartinez Sep 5, 2025
eff8fb8
[sidenav] Refactor component & update UI
TylerAdamMartinez Sep 5, 2025
e537917
[IsTrueChip] Refactor UI for chip consistency
TylerAdamMartinez Sep 5, 2025
d431011
[views/Activities] Refactor entire form UI to improve it on tablets
TylerAdamMartinez Sep 5, 2025
829f589
[ImageUploadWithPreview] Improve image previews
TylerAdamMartinez Sep 5, 2025
9502a01
[ImageUploadWithPreview] Add dblclick feat to preview full image on it
TylerAdamMartinez Sep 5, 2025
d2abb75
Rm unused import
TylerAdamMartinez Sep 5, 2025
b9c5f60
[topbar] Add dicebear avatars & update routes
TylerAdamMartinez Sep 7, 2025
41a5272
[TopbarUserButton] Improve topbar
TylerAdamMartinez Sep 9, 2025
757fd6f
[Settings] Fix broken imports
TylerAdamMartinez Sep 9, 2025
5a52ac9
[api/main] Add support for some public routes
TylerAdamMartinez Sep 9, 2025
056b50a
[routes/chlorides] Add public routes to this router as well
TylerAdamMartinez Sep 9, 2025
fe18f51
[/views] Prevent user fetch on Chlorides & Monitoring Wells views if …
TylerAdamMartinez Sep 9, 2025
5a39ec1
[/settings] Update user table & add routes for users to save their pr…
TylerAdamMartinez Sep 10, 2025
a6cbb4c
[routes/activities] Update route to handle images
TylerAdamMartinez Sep 10, 2025
c3bd175
[Settings] Admin now can set admin page as their redirect option
TylerAdamMartinez Sep 10, 2025
b03867e
[ImageUploadWithPreview] Rm unused state field & refactor previews
TylerAdamMartinez Sep 10, 2025
16723ed
[migrations/20250910204829] Create MeterActivityPhoto table & update …
TylerAdamMartinez Sep 10, 2025
697247e
[routes/meters] Update endpoint to include any photos if exists
TylerAdamMartinez Sep 10, 2025
6745995
[routes/meters] Will not return signed URLs for the frontend to display
TylerAdamMartinez Sep 10, 2025
f95845f
[MeterHistory] Add card to preview images
TylerAdamMartinez Sep 10, 2025
8517328
[routes/meters] Will display image though the backend api via tokens
TylerAdamMartinez Sep 11, 2025
c1a081e
[routes/activities] Update delete route to rm bucket object of images…
TylerAdamMartinez Sep 11, 2025
eaddbd2
[ImageUploadWithPreview] Add MAX_FILE_SIZE limit
TylerAdamMartinez Sep 11, 2025
00fef34
[routes/meters] Add support for price per meter
TylerAdamMartinez Sep 11, 2025
f7a190b
[settings] display name is now editable
TylerAdamMartinez Sep 15, 2025
8524303
[settings] init impl for user based avatars
TylerAdamMartinez Sep 15, 2025
484d7bf
[views/UserManagement] Add display name to user management
TylerAdamMartinez Sep 16, 2025
8f503ea
[views/UserManagement] Add redirect page to user table columns
TylerAdamMartinez Sep 16, 2025
861ab51
[routes/admin] Add display name to use creation
TylerAdamMartinez Sep 16, 2025
32176c6
[ImageUploadWithPreview] Up the MAX_FILE_SIZE from 2 to 5 MB
TylerAdamMartinez Sep 16, 2025
81d4ca4
[routes/chlorides] Add null filtering, median and count calulations t…
TylerAdamMartinez Sep 16, 2025
15949c6
[/migrations] Update WellMeasurements column value to be NULLABLE
TylerAdamMartinez Sep 16, 2025
652349d
[enums/WellStatus] Fix PLUGGED number
TylerAdamMartinez Sep 16, 2025
a45594c
[routes/activities] Add backend support for photos limits
TylerAdamMartinez Sep 17, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,14 @@

from api.routes.activities import activity_router
from api.routes.admin import admin_router
from api.routes.chlorides import chlorides_router
from api.routes.chlorides import authenticated_chlorides_router, public_chlorides_router
from api.routes.maintenance import maintenance_router
from api.routes.meters import meter_router
from api.routes.meters import authenticated_meter_router, public_meter_router
from api.routes.OSE import ose_router
from api.routes.parts import part_router
from api.routes.well_measurements import well_measurement_router
from api.routes.wells import well_router
from api.routes.settings import settings_router
from api.routes.well_measurements import authenticated_well_measurement_router, public_well_measurement_router
from api.routes.wells import authenticated_well_router, public_well_router

from api.security import (
authenticate_user,
Expand Down Expand Up @@ -130,14 +131,19 @@ def login_for_access_token(

authenticated_router.include_router(activity_router)
authenticated_router.include_router(admin_router)
authenticated_router.include_router(chlorides_router)
authenticated_router.include_router(authenticated_chlorides_router)
authenticated_router.include_router(maintenance_router)
authenticated_router.include_router(meter_router)
authenticated_router.include_router(authenticated_meter_router)
authenticated_router.include_router(part_router)
authenticated_router.include_router(well_measurement_router)
authenticated_router.include_router(well_router)
authenticated_router.include_router(authenticated_well_measurement_router)
authenticated_router.include_router(authenticated_well_router)
authenticated_router.include_router(settings_router)

add_pagination(app)

app.include_router(ose_router)
app.include_router(public_meter_router)
app.include_router(public_well_router)
app.include_router(public_chlorides_router)
app.include_router(public_well_measurement_router)
app.include_router(authenticated_router)
27 changes: 25 additions & 2 deletions api/models/main_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,15 @@
func,
Boolean,
Table,
Numeric,
)

from sqlalchemy.orm import (
relationship,
DeclarativeBase,
mapped_column,
Mapped,
deferred,
)
from geoalchemy2 import Geometry
from geoalchemy2.shape import to_shape
from typing import Optional, List

Expand Down Expand Up @@ -149,6 +148,7 @@ class Meters(Base):
contact_name: Mapped[Optional[str]] = mapped_column(String)
contact_phone: Mapped[Optional[str]] = mapped_column(String)
notes: Mapped[Optional[str]] = mapped_column(String)
price: Mapped[Optional[float]] = mapped_column(Numeric(10, 2))

meter_type_id: Mapped[int] = mapped_column(
Integer, ForeignKey("MeterTypeLU.id"), nullable=False
Expand All @@ -174,6 +174,7 @@ class Meters(Base):
location: Mapped["Locations"] = relationship()



class MeterTypeLU(Base):
"""
Meter types
Expand Down Expand Up @@ -238,6 +239,25 @@ class MeterActivities(Base):
notes: Mapped[List["NoteTypeLU"]] = relationship("NoteTypeLU", secondary=Notes)
work_order: Mapped["workOrders"] = relationship()
well: Mapped["Wells"] = relationship("Wells", primaryjoin='MeterActivities.location_id == Wells.location_id', foreign_keys='MeterActivities.location_id', viewonly=True)
photos: Mapped[List["MeterActivityPhotos"]] = relationship("MeterActivityPhotos", back_populates="meter_activity", cascade="all, delete")


class MeterActivityPhotos(Base):
__tablename__ = "MeterActivityPhotos"

id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True, autoincrement=True)
meter_activity_id: Mapped[int] = mapped_column(
Integer, ForeignKey("MeterActivities.id", ondelete="CASCADE"), nullable=False
)
file_name: Mapped[str] = mapped_column(String, nullable=False)
gcs_path: Mapped[str] = mapped_column(String, nullable=False)
uploaded_at: Mapped[DateTime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)

meter_activity: Mapped["MeterActivities"] = relationship(
"MeterActivities", back_populates="photos"
)


class ActivityTypeLU(Base):
Expand Down Expand Up @@ -421,6 +441,9 @@ class Users(Base):
)

user_role: Mapped["UserRoles"] = relationship("UserRoles")
display_name: Mapped[str] = mapped_column(String, nullable=True)
redirect_page: Mapped[str] = mapped_column(String, nullable=True, default="/")
avatar_img: Mapped[str] = mapped_column(String, nullable=True)


# Association table that links roles and their associated scopes
Expand Down
117 changes: 109 additions & 8 deletions api/routes/activities.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
from fastapi import Depends, APIRouter, Query
from fastapi import Depends, APIRouter, Query, File, UploadFile, Form
from fastapi.exceptions import HTTPException
from sqlalchemy.orm import Session, joinedload
from sqlalchemy.exc import IntegrityError
from sqlalchemy import select, text
from datetime import datetime
from typing import List

from api import security
from api.schemas import meter_schemas
from api.models.main_models import (
Expand All @@ -15,6 +14,7 @@
ActivityTypeLU,
Units,
MeterActivities,
MeterActivityPhotos,
MeterObservations,
ServiceTypeLU,
NoteTypeLU,
Expand All @@ -28,25 +28,50 @@
from api.session import get_db
from api.security import get_current_user
from api.enums import ScopedUser, WorkOrderStatus
from pathlib import Path
from google.cloud import storage

import uuid
import json
import os

activity_router = APIRouter()

BUCKET_NAME = os.getenv("GCP_BUCKET_NAME", "")
PHOTO_PREFIX = os.getenv("GCP_PHOTO_PREFIX", "")

MAX_PHOTOS_PER_REQUEST = 2
MAX_PHOTOS_PER_METER = 6

# Process a submitted activity
# Returns 422 when one or more required fields of ActivityForm are not present
# Returns the new MeterActivity on success
@activity_router.post(
"/activities",
response_model=meter_schemas.MeterActivity,
dependencies=[Depends(ScopedUser.ActivityWrite)],
tags=["Activities"],
)
def post_activity(
activity_form: meter_schemas.ActivityForm, db: Session = Depends(get_db), user: Users = Depends(get_current_user)
async def post_activity(
activity: str = Form(...), # JSON string from FormData
photos: list[UploadFile] = File(None),
db: Session = Depends(get_db),
user: Users = Depends(get_current_user),
):
"""
Handles submission of an activity.
Handles submission of an activity (with optional file uploads).
"""

if photos:
if len(photos) > MAX_PHOTOS_PER_REQUEST:
raise HTTPException(
status_code=400,
detail=f"Too many photos uploaded. "
f"Max {MAX_PHOTOS_PER_REQUEST} allowed per request, got {len(photos)}.",
)

try:
activity_form = meter_schemas.ActivityForm.parse_obj(json.loads(activity))
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid activity payload: {e}")

# Set some variables that will be used to determine how the meter is updated
update_meter_state = True
user_level = user.user_role.name
Expand Down Expand Up @@ -122,6 +147,7 @@ def post_activity(
try:
db.add(meter_activity)
db.commit()
db.refresh(meter_activity) # make sure meter_activity.id is available
except IntegrityError as _e:
raise HTTPException(
status_code=409, detail="Activity overlaps with existing activity."
Expand Down Expand Up @@ -235,6 +261,63 @@ def post_activity(

db.commit()


# ---- Handle photo file uploads ----
if photos:
print(f"Received {len(photos)} photos")
print(f"Uploading to bucket={BUCKET_NAME}, prefix={PHOTO_PREFIX}")
client = storage.Client()
bucket = client.bucket(BUCKET_NAME)

for file in photos:
ext = Path(file.filename).suffix or ".jpg"
unique_name = f"{uuid.uuid4()}{ext}"
blob_path = f"{PHOTO_PREFIX}/{meter_activity.id}/{unique_name}"
blob = bucket.blob(blob_path)

# Upload file content directly
try:
contents = await file.read()
print(f"Uploading {file.filename}, size={len(contents)} bytes")

blob.upload_from_string(contents, content_type=file.content_type)
print(f"Uploaded to gs://{BUCKET_NAME}/{blob_path}")
except Exception as e:
print(f"ERROR uploading {file.filename}: {e}")
raise

photo = MeterActivityPhotos(
meter_activity_id=meter_activity.id,
file_name=file.filename,
gcs_path=blob_path,
)
db.add(photo)

db.commit()
print(f"Saved {len(photos)} photos for activity {meter_activity.id}")
db.refresh(meter_activity)

# ---- Enforce per-meter retention ----
all_photos = (
db.query(MeterActivityPhotos)
.join(MeterActivities)
.filter(MeterActivities.meter_id == meter_activity.meter_id)
.order_by(MeterActivityPhotos.uploaded_at.desc())
.all()
)

if len(all_photos) > MAX_PHOTOS_PER_METER:
# keep newest MAX_PHOTOS_PER_METER, delete the rest
to_delete = all_photos[MAX_PHOTOS_PER_METER:]
for old_photo in to_delete:
try:
bucket.blob(old_photo.gcs_path).delete()
except Exception as e:
print(f"Warning: failed to delete {old_photo.gcs_path} from GCS: {e}")
db.delete(old_photo)

db.commit()

return meter_activity

@activity_router.patch(
Expand Down Expand Up @@ -309,6 +392,24 @@ def delete_activity(activity_id: int, db: Session = Depends(get_db)):
# Get the activity
activity = db.scalars(select(MeterActivities).where(MeterActivities.id == activity_id)).first()

if not activity:
raise HTTPException(status_code=404, detail="Activity not found.")

photos = db.scalars(
select(MeterActivityPhotos).where(MeterActivityPhotos.meter_activity_id == activity_id)
).all()

storage_client = storage.Client()
bucket = storage_client.bucket(BUCKET_NAME)

for photo in photos:
try:
blob = bucket.blob(photo.gcs_path)
blob.delete()
print(f"Deleted GCS object: {photo.gcs_path}")
except Exception as e:
print(f"Failed to delete {photo.gcs_path} from bucket: {e}")

# Delete any notes associated with the activity
sql = text('DELETE FROM "Notes" WHERE meter_activity_id = :activity_id')
db.execute(sql, {'activity_id': activity_id})
Expand Down
1 change: 1 addition & 0 deletions api/routes/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ def create_user(user: security_schemas.NewUser, db: Session = Depends(get_db)):
username=user.username,
email=user.email,
full_name=user.full_name,
display_name=user.display_name,
user_role_id=user.user_role_id,
disabled=user.disabled,
hashed_password=pwd_context.hash(user.password),
Expand Down
Loading