Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
75ba2bd
fix(ose): Add null guard to _serialize_activity()
TylerAdamMartinez Mar 25, 2026
193b332
fix(ose): Corrected incorrect column name
TylerAdamMartinez Mar 26, 2026
8d05303
feat(services/work_orders): Sends meter size to frontend
TylerAdamMartinez Apr 9, 2026
865b648
feat(TristateToggle): Allow for optional custom labels
TylerAdamMartinez Apr 9, 2026
ee10b31
fix(WellSelectionTable): Improve deep links to include the meter stat…
TylerAdamMartinez Apr 9, 2026
afa241f
chore(interfaces|enums): Update frontend modals to match backend resp…
TylerAdamMartinez Apr 9, 2026
8c8bba1
feat(routes/manage/meters): Add new search param m_sizeSort
TylerAdamMartinez Apr 9, 2026
34c58da
feat(MetersView): impl sort by size
TylerAdamMartinez Apr 9, 2026
2f2e3e8
chore(WellSelectionTable): Update meter url params to reflect new siz…
TylerAdamMartinez Apr 9, 2026
e063313
feat(MeterDetailsFields): Add skeleton to let the user know it's stil…
TylerAdamMartinez Apr 9, 2026
d238d93
feat(MeterSelection): Impl size sorting
TylerAdamMartinez Apr 9, 2026
01f5711
feat(routes/admin): Add ability to impersonate people for admins
TylerAdamMartinez Apr 9, 2026
ff2eb7c
feat(docker-compose): Add VITE_APP_ENV letting the frontend know the …
TylerAdamMartinez Apr 9, 2026
53f29bb
chore(.env.*): Add VITE_APP_ENV
TylerAdamMartinez Apr 9, 2026
b82e2c5
feat(Impersonation): Allow for impersonations in dev & pre-prod
TylerAdamMartinez Apr 9, 2026
90a9b11
fix(services/activities): Used parts when we needed to use partsused
TylerAdamMartinez Apr 9, 2026
0f488fa
chore(api): Add typing to api
TylerAdamMartinez Apr 13, 2026
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
18 changes: 12 additions & 6 deletions api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"""

import os
from typing import Optional
from dotenv import load_dotenv

from pathlib import Path
Expand All @@ -19,16 +20,21 @@


class Settings:
POSTGRES_USER: str = os.getenv("POSTGRES_USER")
POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD")
POSTGRES_HOST: str = os.getenv("POSTGRES_HOST")
POSTGRES_PORT: str = os.getenv(
POSTGRES_USER: Optional[str] = os.getenv("POSTGRES_USER")
POSTGRES_PASSWORD: Optional[str] = os.getenv("POSTGRES_PASSWORD")
POSTGRES_HOST: Optional[str] = os.getenv("POSTGRES_HOST")
POSTGRES_PORT: str | int = os.getenv(
"POSTGRES_PORT", 5432
) # default postgres port is 5432
POSTGRES_DB: str = os.getenv("POSTGRES_DB")
JWT_SECRET_KEY: str | None = os.getenv("JWT_SECRET_KEY")
POSTGRES_DB: Optional[str] = os.getenv("POSTGRES_DB")
JWT_SECRET_KEY: Optional[str] = os.getenv("JWT_SECRET_KEY")
JWT_ALGORITHM: str = os.getenv("JWT_ALGORITHM", "HS256")
ACCESS_TOKEN_EXPIRE_HOURS: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_HOURS", "8"))
APP_ENV: str = os.getenv(
"APP_ENV",
"production" if os.getenv("APPDB_ENV") == ".env_production" else "development",
)
ALLOW_IMPERSONATION: bool = APP_ENV in {"development", "pre-production"}
DATABASE_URL = f"postgresql+psycopg://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}"


Expand Down
1 change: 1 addition & 0 deletions api/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class MeterSortByField(Enum):
RANumber = "ra_number"
WaterUsers = "water_users"
TRSS = "trss"
MeterSize = "meter_size"


class WellSortByField(Enum):
Expand Down
91 changes: 81 additions & 10 deletions api/routes/admin.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
from fastapi import Depends, APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session, joinedload, undefer
from sqlalchemy import select
from typing import List
from passlib.context import CryptContext
from datetime import timedelta

from fastapi import Depends, APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session, joinedload, undefer
from sqlalchemy import select
from typing import List
from passlib.context import CryptContext

from api.models.user import Users, UserRoles, SecurityScopes

from api.schemas import security
from api.schemas import admin
from api.session import get_db
from api.session import get_db
from api.routes.utils import _patch
from api.auth.dependencies import ScopedUser
from api.auth.session_tracking import create_user_session
from api.security import create_access_token, ACCESS_TOKEN_EXPIRE_HOURS
from api.config import settings

from pathlib import Path
from google.cloud import storage
Expand Down Expand Up @@ -145,7 +150,7 @@ def get_user_admin(id: int, db: Session = Depends(get_db)):
dependencies=[Depends(ScopedUser.Admin)],
tags=["Admin"],
)
def get_users_admin(db: Session = Depends(get_db)):
def get_users_admin(db: Session = Depends(get_db)):
"""
Admin-specific users list that includes sensitive information such as username and role
"""
Expand All @@ -159,8 +164,74 @@ def get_users_admin(db: Session = Depends(get_db)):
)
)
.unique()
.all()
)
.all()
)


@admin_router.post(
"/users/{id}/impersonate",
response_model=security.Token,
dependencies=[Depends(ScopedUser.Admin)],
tags=["Admin"],
)
def impersonate_user(
id: int,
request: Request,
db: Session = Depends(get_db),
current_admin: Users = Depends(ScopedUser.Admin),
):
if not settings.ALLOW_IMPERSONATION:
raise HTTPException(
status_code=403,
detail="User impersonation is only available in development and pre-production environments.",
)

target_user = db.scalars(
select(Users)
.options(
undefer(Users.username),
undefer(Users.user_role_id),
undefer(Users.email),
joinedload(Users.user_role).joinedload(UserRoles.security_scopes),
)
.where(Users.id == id)
).first()

if not target_user:
raise HTTPException(status_code=404, detail="User not found")

if target_user.disabled:
raise HTTPException(status_code=400, detail="Cannot impersonate a disabled user")

user_session = create_user_session(db=db, user=target_user, request=request)

access_token = create_access_token(
data={
"sub": target_user.username,
"sid": user_session.session_identifier,
"scopes": list(
map(
lambda scope: scope.scope_string,
target_user.user_role.security_scopes,
)
),
},
expires_delta=timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS),
)
user_response = security.User(**target_user.__dict__)
db.commit()

return {
"access_token": access_token,
"token_type": "bearer",
"user": user_response,
"session_identifier": user_session.session_identifier,
"impersonation": security.ImpersonationContext(
impersonator_user_id=current_admin.id,
impersonator_full_name=current_admin.full_name,
impersonator_display_name=current_admin.display_name,
),
}


@admin_router.get(
Expand Down
10 changes: 9 additions & 1 deletion api/routes/meters.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ def sort_by_field_to_schema_field(name: MeterSortByField):
case MeterSortByField.TRSS:
return Locations.trss

case MeterSortByField.MeterSize:
return MeterTypeLU.size

# If 'Warehouse' is in the filter, add 'On Hold' to the filter
if (
MeterStatus.OnHold not in filter_by_status
Expand All @@ -69,10 +72,15 @@ def sort_by_field_to_schema_field(name: MeterSortByField):
# joinedload loads relationships, outer joins on relationship tables makes them search/sortable
query_statement = (
select(Meters)
.options(joinedload(Meters.well), joinedload(Meters.status))
.options(
joinedload(Meters.well),
joinedload(Meters.status),
joinedload(Meters.meter_type),
)
.join(Wells, isouter=True)
.join(Locations, isouter=True)
.join(MeterStatusLU, isouter=True)
.join(MeterTypeLU, isouter=True)
.where(MeterStatusLU.status_name.in_(filter_by_status_str))
)

Expand Down
5 changes: 5 additions & 0 deletions api/schemas/meter.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ class MeterRegister(ORMBase):

# The minimal information used by the meters list
class MeterListDTO(ORMBase):
class MeterTypeDTO(ORMBase):
size: float | None = None

class WellDTO(ORMBase):
ra_number: str | None = None
name: str | None = None
Expand All @@ -56,6 +59,7 @@ class StatusDTO(ORMBase):
price: Decimal | None = None
well: WellDTO | None = None
location: LocationDTO | None = None
meter_type: MeterTypeDTO | None = None
status: StatusDTO | None = None


Expand Down Expand Up @@ -305,6 +309,7 @@ class WorkOrder(ORMBase):
creator: str | None = None
meter_id: int # Might be needed in certain situations
meter_serial: str
meter_status: str | None = None
title: str
description: str | None = None
status: str
Expand Down
17 changes: 12 additions & 5 deletions api/schemas/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class NewUser(ORMBase):
password: str


class User(ORMBase):
class User(ORMBase):
username: str | None = None
email: str | None = None
full_name: str | None = None
Expand All @@ -47,15 +47,22 @@ class User(ORMBase):
user_role: UserRole | None = None

display_name: str | None = None
redirect_page: str | None = None
avatar_img: str | None = None


redirect_page: str | None = None
avatar_img: str | None = None


class ImpersonationContext(BaseModel):
impersonator_user_id: int
impersonator_full_name: str | None = None
impersonator_display_name: str | None = None


class Token(BaseModel):
access_token: str
token_type: str
user: User
session_identifier: str | None = None
impersonation: ImpersonationContext | None = None


class TokenData(ORMBase):
Expand Down
15 changes: 8 additions & 7 deletions api/services/activities.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import datetime
from collections import Counter

from fastapi import HTTPException
from sqlalchemy import select, text
Expand All @@ -17,7 +18,7 @@
ServiceTypeLU,
Units,
)
from api.models.part import Parts
from api.models.part import PartsUsed
from api.models.user import Users
from api.models.well import Wells
from api.schemas import meter
Expand Down Expand Up @@ -133,12 +134,12 @@ async def create_activity(
).first()
meter_activity.notes.append(status_note_type)

used_parts = db.scalars(
select(Parts).where(Parts.id.in_(activity_form.part_used_ids))
).all()
meter_activity.parts_used = used_parts
for used_part in used_parts:
used_part.count -= 1
part_counts = Counter(activity_form.part_used_ids)

meter_activity.parts_used_links = [
PartsUsed(part_id=part_id, count=count)
for part_id, count in part_counts.items()
]

services = db.scalars(
select(ServiceTypeLU).where(
Expand Down
9 changes: 7 additions & 2 deletions api/services/ose.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from datetime import datetime
import os

from api.models.part import PartsUsed
from fastapi import HTTPException
from sqlalchemy import and_, select
from sqlalchemy.orm import Session, joinedload, selectinload
Expand Down Expand Up @@ -57,7 +58,9 @@ def _serialize_activity(
) -> ose.ActivityDTO:
notes_strings = [note.note for note in activity.notes]
parts_used_strings = [
f"{part.part_type.name} ({part.part_number})" for part in activity.parts_used_links
f"{part.part.part_number}{f' - {part.part.description}' if part.part.description else ''} x{part.count}"
for part in activity.parts_used_links
if part.part
]
services_performed_strings = [
service.service_name for service in activity.services_performed
Expand Down Expand Up @@ -132,10 +135,12 @@ def get_shared_history(
select(MeterActivities)
.options(
joinedload(MeterActivities.activity_type),
joinedload(MeterActivities.parts_used_links),
joinedload(MeterActivities.parts_used_links).joinedload(PartsUsed.part),
joinedload(MeterActivities.meter),
joinedload(MeterActivities.work_order),
joinedload(MeterActivities.well),
joinedload(MeterActivities.notes),
joinedload(MeterActivities.services_performed),
selectinload(MeterActivities.photos),
)
.filter(
Expand Down
4 changes: 3 additions & 1 deletion api/services/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ def open_activity_photo(photo: MeterActivityPhotos):
blob = bucket.blob(photo.gcs_path)

if not blob.exists(client=client):
raise HTTPException(status_code=404, detail="Photo file missing from storage")
raise HTTPException(
status_code=404, detail="Photo file missing from storage"
)

blob.reload(client=client)
content_type = blob.content_type or "application/octet-stream"
Expand Down
16 changes: 14 additions & 2 deletions api/services/work_orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def _work_order_query():
select(workOrders)
.options(
joinedload(workOrders.status),
joinedload(workOrders.meter),
joinedload(workOrders.meter).joinedload(Meters.status),
joinedload(workOrders.assigned_user),
)
)
Expand All @@ -28,7 +28,10 @@ def _load_associated_activities(db: Session, work_order_ids: list[int]):

relevant_activities = db.scalars(
select(MeterActivities)
.options(joinedload(MeterActivities.location))
.options(
joinedload(MeterActivities.location),
joinedload(MeterActivities.meter).joinedload(Meters.status),
)
.where(MeterActivities.work_order_id.in_(work_order_ids))
).all()

Expand All @@ -42,6 +45,9 @@ def _load_associated_activities(db: Session, work_order_ids: list[int]):
"description": activity.description,
"submitting_user_id": activity.submitting_user_id,
"meter_id": activity.meter_id,
"meter_status": activity.meter.status.status_name
if activity.meter and activity.meter.status
else None,
"activity_type_id": activity.activity_type_id,
"location_id": activity.location_id,
"location_name": activity.location.name if activity.location else None,
Expand All @@ -64,6 +70,9 @@ def _serialize_work_order(
creator=work_order.creator,
meter_id=work_order.meter.id,
meter_serial=work_order.meter.serial_number,
meter_status=work_order.meter.status.status_name
if work_order.meter and work_order.meter.status
else None,
title=work_order.title,
description=work_order.description,
status=work_order.status.name,
Expand Down Expand Up @@ -122,6 +131,9 @@ def list_work_orders(
"creator": work_order.creator,
"meter_id": work_order.meter.id,
"meter_serial": work_order.meter.serial_number,
"meter_status": work_order.meter.status.status_name
if work_order.meter and work_order.meter.status
else None,
"title": work_order.title,
"description": work_order.description,
"status": work_order.status.name,
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.development.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ services:
build:
context: ./frontend
dockerfile: ./Dockerfile
args:
VITE_APP_ENV: pre-production
env_file:
- ./frontend/.env.devserver
ports:
Expand All @@ -31,6 +33,7 @@ services:
dockerfile: ./Dockerfile
working_dir: /app
environment:
- APP_ENV=pre-production
- API_BASE_URL=https://pvacd-dev.newmexicowaterdata.org/api/v1
- GCP_BUCKET_NAME=pvacd
- GCP_WOODPECKER_BUCKET_NAME=pvacd-woodpecker
Expand Down
Loading
Loading