diff --git a/.gitignore b/.gitignore index 483a01fe..f768459b 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,7 @@ share/python-wheels/ .installed.cfg *.egg MANIFEST +.python-version # PyInstaller # Usually these files are written by a python script from a template @@ -227,4 +228,4 @@ cython_debug/ /api/backupdb/*.sql # dependencies /node_modules -/frontend/node_modules \ No newline at end of file +/frontend/node_modules diff --git a/LICENSE b/LICENSE index 261eeb9e..a1eee53f 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2023 New Mexico Water Data Initiative Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/api/__init__.py b/api/__init__.py index 72f778cc..e69de29b 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -1,18 +0,0 @@ -# =============================================================================== -# Copyright 2022 ross -# -# 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. -# =============================================================================== - - -# ============= EOF ============================================= diff --git a/api/backupdb/restore.sh b/api/backupdb/restore.sh deleted file mode 100644 index edfb5c15..00000000 --- a/api/backupdb/restore.sh +++ /dev/null @@ -1 +0,0 @@ -pg_restore -d appdb_local appdb.sql \ No newline at end of file diff --git a/api/config.py b/api/config.py index 879ce00b..a5011828 100644 --- a/api/config.py +++ b/api/config.py @@ -30,4 +30,3 @@ class Settings: settings = Settings() -# ============= EOF ============================================= diff --git a/api/dbsetup.py b/api/dbsetup.py deleted file mode 100644 index f9418045..00000000 --- a/api/dbsetup.py +++ /dev/null @@ -1,236 +0,0 @@ -# # =============================================================================== -# This script builds the database from scratch and so should only be run as needed -# # =============================================================================== - -import os -import api.models -from api.security import get_password_hash -from sqlalchemy import create_engine -from sqlalchemy.sql import text -from api.session import SessionLocal -from .config import settings - -# Set up a connection -SQLALCHEMY_DATABASE_URL = settings.DATABASE_URL -engine = create_engine(SQLALCHEMY_DATABASE_URL) - -print("Setting up the database") -api.models.main_models.Base.metadata.create_all(engine) - -# Add initial users, roles, and scopes -db = SessionLocal() - -SecurityScopes = api.models.security_models.SecurityScopes -UserRoles = api.models.security_models.UserRoles -Users = api.models.security_models.Users - -admin_scope = SecurityScopes(scope_string="admin", description="Admin-specific scope.") -meter_write_scope = SecurityScopes( - scope_string="meter:write", description="Write meters" -) -activities_write_scope = SecurityScopes( - scope_string="activities:write", description="Write activities" -) -well_measurements_write_scope = SecurityScopes( - scope_string="well_measurement:write", - description="Write well measurements, i.e. Water Levels and Chlorides", -) -reports_run_scope = SecurityScopes( - scope_string="reports:run", description="Run reports" -) -read_scope = SecurityScopes(scope_string="read", description="Read all data.") -ose_scope = SecurityScopes(scope_string="ose", description="Scope given to the OSE") - -technician_role = UserRoles( - name="Technician", - security_scopes=[ - read_scope, - meter_write_scope, - activities_write_scope, - well_measurements_write_scope, - reports_run_scope, - ], -) -admin_role = UserRoles( - name="Admin", - security_scopes=[ - read_scope, - meter_write_scope, - activities_write_scope, - well_measurements_write_scope, - reports_run_scope, - ose_scope, - admin_scope, - ], -) -ose_role = UserRoles( - name="OSE", - security_scopes=[read_scope, ose_scope], -) - -admin_user = Users( - full_name="NMWDI Admin", - username="nmwdi_admin", - email="johndoe@example.com", - hashed_password=get_password_hash("testthisapp"), - user_role=technician_role, -) - -db.add_all( - [ - admin_scope, - meter_write_scope, - activities_write_scope, - well_measurements_write_scope, - reports_run_scope, - read_scope, - ose_scope, - technician_role, - admin_role, - ose_role, - admin_user, - ] -) - -db.commit() -db.close() - - -# Load seed data from CSV -# Follows - https://stackoverflow.com/questions/31394998/using-sqlalchemy-to-load-csv-file-into-a-database -# Get the psycopg2 connector - enables running of lower level functions -conn = engine.raw_connection() -cursor = conn.cursor() - -with open("../PVACDdb_migration/csv_data/tables/metertypes.csv", "r") as f: - qry = 'COPY "MeterTypeLU"(id,brand,series,model_number,size,description) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/NoteTypeLU.csv", "r") as f: - qry = 'COPY "NoteTypeLU"(id,note,details,slug) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/ServiceTypeLU.csv", "r") as f: - qry = 'COPY "ServiceTypeLU"(id,service_name,description) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/landowners.csv", "r") as f: - qry = 'COPY "LandOwners"(organization,address,city,state,zip,phone,mobile,note,id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/meterstatus.csv", "r") as f: - qry = 'COPY "MeterStatusLU"(id,status_name,description) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/observedproperties.csv", "r") as f: - qry = 'COPY "ObservedPropertyTypeLU"(id,name,description,context) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/units.csv", "r") as f: - qry = 'COPY "Units"(id,name,name_short,description) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/propertyunits.csv", "r") as f: - qry = 'COPY "PropertyUnits"(property_id,unit_id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/locationtypeLU.csv", "r") as f: - qry = 'COPY "LocationTypeLU"(id,type_name,description) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/locations.csv", "r") as f: - qry = 'COPY "Locations"(id,name,type_id,latitude,longitude,trss) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/welluseLU.csv", "r") as f: - qry = 'COPY "WellUseLU"(id,use_type,code,description) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/wells.csv", "r") as f: - qry = 'COPY "Wells"(id,name,use_type_id,location_id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/meters.csv", "r") as f: - qry = 'COPY "Meters"(serial_number,meter_type_id,status_id,location_id,well_id,id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/activities.csv", "r") as f: - qry = 'COPY "ActivityTypeLU"(id,name,description,permission) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open( - "../PVACDdb_migration/csv_data/testing/devdata_wellMeasurement.csv", "r" -) as f: - qry = 'COPY "WellMeasurements"(timestamp,value,well_id,observed_property_id,submitting_user_id,unit_id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/parttypeLU.csv", "r") as f: - qry = 'COPY "PartTypeLU"(id,name,description) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/parts.csv", "r") as f: - qry = 'COPY "Parts"(id,part_number,part_type_id,description,count,note) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/partsassociated.csv", "r") as f: - qry = 'COPY "PartAssociation"(meter_type_id,part_id,commonly_used) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -# Only load the following for local testing -testing = False -if testing: - with open("api/data/testdata_users.csv", "r") as f: - qry = 'COPY "Users"(id, username, full_name, email, hashed_password, disabled, user_role_id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - - # with open("api/data/testdata_meterobservations.csv", "r") as f: - # qry = 'COPY "MeterObservations"(timestamp, value, notes, submitting_user_id, meter_id, observed_property_type_id, unit_id, location_id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - # cursor.copy_expert(qry, f) - - # with open("api/data/testdata_meteractivities.csv", "r") as f: - # qry = 'COPY "MeterActivities"(id, timestamp_start, timestamp_end, notes, submitting_user_id, meter_id, activity_type_id, location_id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - # cursor.copy_expert(qry, f) - - # with open("api/data/testdata_partsused.csv", "r") as f: - # qry = 'COPY "PartsUsed"(meter_activity_id, part_id, count) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - # cursor.copy_expert(qry, f) - - with open("api/data/devdata_chloridemeasurements.csv", "r") as f: - qry = 'COPY "WellMeasurements"(timestamp,value,well_id,observed_property_id,submitting_user_id,unit_id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -# Create geometries from location lat longs -cursor.execute('update "Locations" set geom = ST_MakePoint(longitude,latitude)') - -conn.commit() -conn.close() - - -# SQL for activity type security scopes if we ever decide to go that route -# INSERT INTO "SecurityScopes" (scope_string, description) -# VALUES -# ('activities:install', 'Submit install activities'), -# ('activities:uninstall', 'Submit install activities'), -# ('activities:general_maintenance', 'Submit general maintenance activities'), -# ('activities:preventative_maintenance', 'Submit preventative maintenance activities'), -# ('activities:repair', 'Submit repair activities'), -# ('activities:rate_meter', 'Submit rate meter activities'), -# ('activities:sell', 'Submit sell activities'), -# ('activities:scrap', 'Submit scrap activities'); - -# INSERT INTO "ScopesRoles" (security_scope_id, user_role_id) -# VALUES -# (7, 2), -# (8, 2), -# (9, 2), -# (10, 2), -# (11, 2), -# (12, 2), -# (13, 2), -# (14, 2), -# (7, 1), -# (8, 1), -# (9, 1), -# (10, 1), -# (11, 1), -# (12, 1); diff --git a/api/main.py b/api/main.py index befc01cd..7c6edd7c 100644 --- a/api/main.py +++ b/api/main.py @@ -9,8 +9,12 @@ from api.routes.activities import activity_router, public_activity_router from api.routes.admin import admin_router from api.routes.chlorides import authenticated_chlorides_router, public_chlorides_router -from api.routes.maintenance import maintenance_router +from api.routes.maintenance import ( + authenticated_maintenance_router, + public_maintenance_router, +) from api.routes.meters import authenticated_meter_router, public_meter_router +from api.routes.notifications import notifications_router from api.routes.OSE import ose_router from api.routes.parts import part_router from api.routes.settings import settings_router @@ -40,6 +44,7 @@ "name": "WaterLevels", "description": "Groundwater Depth and Chloride Measurement Related Endpoints", }, + {"name": "Notifications", "description": "Notification related endpoints"}, {"name": "OSE", "description": "Endpoints Used by the OSE to Generate Reports"}, {"name": "Admin", "description": "Admin Functionality Related Endpoints"}, {"name": "Login", "description": "User Auth and Token Related Endpoints"}, @@ -116,8 +121,9 @@ def login_for_access_token( authenticated_router.include_router(activity_router) authenticated_router.include_router(admin_router) authenticated_router.include_router(authenticated_chlorides_router) -authenticated_router.include_router(maintenance_router) +authenticated_router.include_router(authenticated_maintenance_router) authenticated_router.include_router(authenticated_meter_router) +authenticated_router.include_router(notifications_router) authenticated_router.include_router(part_router) authenticated_router.include_router(authenticated_well_measurement_router) authenticated_router.include_router(authenticated_well_router) @@ -130,5 +136,6 @@ def login_for_access_token( app.include_router(public_meter_router) app.include_router(public_well_router) app.include_router(public_chlorides_router) +app.include_router(public_maintenance_router) app.include_router(public_well_measurement_router) app.include_router(authenticated_router) diff --git a/api/models/main_models.py b/api/models/main_models.py index 4892cc78..c289ced4 100644 --- a/api/models/main_models.py +++ b/api/models/main_models.py @@ -9,6 +9,7 @@ Boolean, Table, Numeric, + Date, ) from sqlalchemy.orm import ( relationship, @@ -18,6 +19,7 @@ deferred, ) from geoalchemy2.shape import to_shape +from datetime import date from typing import Optional, List @@ -31,9 +33,6 @@ class Base(DeclarativeBase): __name__: str -# ---------- Parts/Services/Notes ------------ - - class PartTypeLU(Base): """ The types of parts @@ -64,7 +63,7 @@ class Parts(Base): part_number: Mapped[str] = mapped_column(String, unique=True, nullable=False) description: Mapped[Optional[str]] vendor: Mapped[Optional[str]] - count: Mapped[int] = mapped_column(Integer, default=0) + initial_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) note: Mapped[Optional[str]] in_use: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) commonly_used: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) @@ -80,14 +79,40 @@ class Parts(Base): secondary=PartAssociation ) + parts_used_links: Mapped[list["PartsUsed"]] = relationship( + back_populates="part", + cascade="all, delete-orphan", + ) -# Association table that links parts and the meter activity they were used on -PartsUsed = Table( - "PartsUsed", - Base.metadata, - Column("meter_activity_id", ForeignKey("MeterActivities.id"), nullable=False), - Column("part_id", ForeignKey("Parts.id"), nullable=False), -) + +class PartsUsed(Base): + __tablename__ = "PartsUsed" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + meter_activity_id: Mapped[int] = mapped_column( + ForeignKey("MeterActivities.id"), nullable=False + ) + part_id: Mapped[int] = mapped_column(ForeignKey("Parts.id"), nullable=False) + + count: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + + part: Mapped["Parts"] = relationship(back_populates="parts_used_links") + meter_activity: Mapped["MeterActivities"] = relationship( + back_populates="parts_used_links" + ) + + +class PartsAdded(Base): + __tablename__ = "PartsAdded" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + part_id: Mapped[int] = mapped_column(ForeignKey("Parts.id"), nullable=False) + + count: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + date: Mapped[date] = mapped_column(Date, nullable=False) # default handled by DB + note: Mapped[str | None] = mapped_column(String, nullable=True) + + part: Mapped["Parts"] = relationship class ServiceTypeLU(Base): @@ -134,8 +159,6 @@ class NoteTypeLU(Base): Column("note_type_id", ForeignKey("NoteTypeLU.id"), nullable=False), ) -# --------- Meter Related Tables --------- - class Meters(Base): """ @@ -189,8 +212,6 @@ class MeterTypeLU(Base): description: Mapped[str] = mapped_column(String) in_use: Mapped[bool] = mapped_column(Boolean, nullable=False) - # parts: Mapped[List["Parts"]] = relationship(secondary=PartAssociation) - class MeterStatusLU(Base): """ @@ -233,7 +254,6 @@ class MeterActivities(Base): activity_type: Mapped["ActivityTypeLU"] = relationship() location: Mapped["Locations"] = relationship() - parts_used: Mapped[List["Parts"]] = relationship("Parts", secondary=PartsUsed) services_performed: Mapped[List["ServiceTypeLU"]] = relationship( "ServiceTypeLU", secondary=ServicesPerformed ) @@ -249,6 +269,11 @@ class MeterActivities(Base): "MeterActivityPhotos", back_populates="meter_activity", cascade="all, delete" ) + parts_used_links: Mapped[list["PartsUsed"]] = relationship( + back_populates="meter_activity", + cascade="all, delete-orphan", + ) + class MeterActivityPhotos(Base): __tablename__ = "MeterActivityPhotos" @@ -343,7 +368,6 @@ class Units(Base): description: Mapped[str] = mapped_column(String) -# Association table that links observed property types and their appropriate units PropertyUnits = Table( "PropertyUnits", Base.metadata, @@ -351,8 +375,6 @@ class Units(Base): Column("unit_id", ForeignKey("Units.id"), nullable=False), ) -# ---------- Other Tables --------------- - class Locations(Base): """ @@ -370,7 +392,6 @@ class Locations(Base): quarter: Mapped[int] = mapped_column(Integer) half_quarter: Mapped[int] = mapped_column(Integer) quarter_quarter: Mapped[int] = mapped_column(Integer) - # geom = mapped_column(Geometry("POINT")) # SQLAlchemy/FastAPI has some issue sending this type_id: Mapped[int] = mapped_column( Integer, ForeignKey("LocationTypeLU.id"), nullable=False @@ -428,9 +449,6 @@ class LandOwners(Base): note: Mapped[str] = mapped_column(String) -# ----------- Security Tables --------------- - - class Users(Base): """ All info about a user of the app @@ -454,6 +472,64 @@ class Users(Base): 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) + notifications: Mapped[List["Notifications"]] = relationship( + "Notifications", + back_populates="user", + cascade="all, delete-orphan", + foreign_keys="Notifications.user_id", + ) + created_notifications: Mapped[List["Notifications"]] = relationship( + "Notifications", + back_populates="creator", + foreign_keys="Notifications.created_by", + ) + + +class NotificationTypeLU(Base): + __tablename__ = "notification_type_lu" + + name: Mapped[str] = mapped_column(String(50), nullable=False, unique=True) + description: Mapped[Optional[str]] = mapped_column(String) + + notifications: Mapped[List["Notifications"]] = relationship( + "Notifications", back_populates="notification_type" + ) + + +class Notifications(Base): + __tablename__ = "notifications" + + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("Users.id", ondelete="CASCADE", onupdate="CASCADE"), index=True + ) + notification_type_id: Mapped[int] = mapped_column( + Integer, + ForeignKey( + "notification_type_lu.id", ondelete="RESTRICT", onupdate="CASCADE" + ), + index=True, + ) + created_by: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("Users.id", ondelete="SET NULL", onupdate="CASCADE"), index=True + ) + title: Mapped[str] = mapped_column(String(255), nullable=False) + message: Mapped[str] = mapped_column(String, nullable=False) + link: Mapped[Optional[str]] = mapped_column(String(500)) + is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + created_at: Mapped[DateTime] = mapped_column( + DateTime, nullable=False, server_default=func.now(), index=True + ) + read_at: Mapped[Optional[DateTime]] = mapped_column(DateTime) + + user: Mapped["Users"] = relationship( + "Users", back_populates="notifications", foreign_keys=[user_id] + ) + creator: Mapped[Optional["Users"]] = relationship( + "Users", back_populates="created_notifications", foreign_keys=[created_by] + ) + notification_type: Mapped["NotificationTypeLU"] = relationship( + "NotificationTypeLU", back_populates="notifications" + ) # Association table that links roles and their associated scopes @@ -485,9 +561,6 @@ class UserRoles(Base): ) -# ------------ Wells -------------- - - class WellUseLU(Base): """ The type of well @@ -615,9 +688,6 @@ class workOrders(Base): ) ose_request_id: Mapped[int] = mapped_column(Integer, nullable=True) - # Associated Activities - # associated_activities: Mapped[List['MeterActivities']] = relationship("MeterActivities") - meter: Mapped["Meters"] = relationship() status: Mapped["workOrderStatusLU"] = relationship() assigned_user: Mapped["Users"] = relationship() diff --git a/api/route_util.py b/api/route_util.py index f73e5c1c..389c70f2 100644 --- a/api/route_util.py +++ b/api/route_util.py @@ -1,18 +1,3 @@ -# =============================================================================== -# Copyright 2022 ross -# -# 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 fastapi import HTTPException from pydantic import BaseModel @@ -53,6 +38,3 @@ def _get(db, table, dbid): raise HTTPException(status_code=404, detail=f"{table}.{dbid} not found") return db_item - - -# ============= EOF ============================================= diff --git a/api/routes/__init__.py b/api/routes/__init__.py index 34d53041..e69de29b 100644 --- a/api/routes/__init__.py +++ b/api/routes/__init__.py @@ -1 +0,0 @@ -# =============================================================================== diff --git a/api/routes/activities.py b/api/routes/activities.py index 0ff7d738..becca741 100644 --- a/api/routes/activities.py +++ b/api/routes/activities.py @@ -1,11 +1,11 @@ from fastapi import Depends, APIRouter, Query, File, UploadFile, Form from fastapi.exceptions import HTTPException from fastapi.responses import StreamingResponse -from sqlalchemy.orm import Session, joinedload +from sqlalchemy.orm import Session, joinedload, undefer from sqlalchemy.exc import IntegrityError -from sqlalchemy import select, text +from sqlalchemy import select, text, or_ from datetime import datetime -from typing import List +from typing import List, Annotated from api import security from api.schemas import meter_schemas from api.models.main_models import ( @@ -622,7 +622,11 @@ def get_activity_types( tags=["Activities"], ) def get_users(db: Session = Depends(get_db)): - return db.scalars(select(Users).where(Users.disabled == False)).all() + return db.scalars( + select(Users) + .options(undefer(Users.user_role_id)) + .where(Users.disabled == False) + ).all() @activity_router.get( @@ -678,11 +682,16 @@ def get_note_types(db: Session = Depends(get_db)): tags=["Work Orders"], ) def get_work_orders( - filter_by_status: list[WorkOrderStatus] = Query(["Open"]), + filter_by_status: Annotated[list[WorkOrderStatus], Query()] = [ + WorkOrderStatus.Open + ], start_date: datetime = Query(datetime.strptime("2024-06-01", "%Y-%m-%d")), + work_order_id: Annotated[list[int] | None, Query()] = None, + assigned_user_id: int | None = None, + q: str | None = None, db: Session = Depends(get_db), ): - query_stmt = ( + stmt = ( select(workOrders) .options( joinedload(workOrders.status), @@ -693,7 +702,26 @@ def get_work_orders( .where(workOrderStatusLU.name.in_(filter_by_status)) .where(workOrders.date_created >= start_date) ) - work_orders = db.scalars(query_stmt).all() + + if work_order_id: + stmt = stmt.where(workOrders.id.in_(work_order_id)) + + if assigned_user_id: + stmt = stmt.where(workOrders.assigned_user_id == assigned_user_id) + + if q: + q_like = f"%{q.strip()}%" + stmt = stmt.where( + or_( + workOrders.title.ilike(q_like), + workOrders.description.ilike(q_like), + workOrders.creator.ilike(q_like), + workOrders.notes.ilike(q_like), + workOrders.meter.has(Meters.serial_number.ilike(q_like)), + ) + ) + + work_orders = db.scalars(stmt).all() # grab activities separately relevant_activities = db.scalars( diff --git a/api/routes/admin.py b/api/routes/admin.py index b40e0763..71bd8703 100644 --- a/api/routes/admin.py +++ b/api/routes/admin.py @@ -112,6 +112,33 @@ def create_user(user: security_schemas.NewUser, db: Session = Depends(get_db)): return qualified_user +@admin_router.get( + "/users/{id}", + response_model=security_schemas.User, + dependencies=[Depends(ScopedUser.Admin)], + tags=["Admin"], +) +def get_user_admin(id: int, db: Session = Depends(get_db)): + """ + Admin-specific single user endpoint (includes username/email/role) + """ + user = db.scalars( + select(Users) + .options( + undefer(Users.username), + undefer(Users.user_role_id), + undefer(Users.email), + joinedload(Users.user_role), + ) + .where(Users.id == id) + ).first() + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + return user + + @admin_router.get( "/usersadmin", response_model=List[security_schemas.User], diff --git a/api/routes/maintenance.py b/api/routes/maintenance.py index 2a4b3449..4adff0cd 100644 --- a/api/routes/maintenance.py +++ b/api/routes/maintenance.py @@ -1,4 +1,5 @@ from fastapi import Depends, APIRouter, Query +from sqlalchemy import func from sqlalchemy.orm import Session from pydantic import BaseModel from typing import List @@ -14,6 +15,8 @@ MeterActivities, ActivityTypeLU, Locations, + workOrders, + workOrderStatusLU, ) from api.session import get_db from api.enums import ScopedUser @@ -32,7 +35,8 @@ autoescape=select_autoescape(["html", "xml"]), ) -maintenance_router = APIRouter() +authenticated_maintenance_router = APIRouter() +public_maintenance_router = APIRouter() class MeterSummary(BaseModel): @@ -55,7 +59,50 @@ class MaintenanceSummaryResponse(BaseModel): table_rows: List[MaintenanceRow] -@maintenance_router.get( +class HomeSummaryResponse(BaseModel): + completed_work_orders: int + repairs_processed: int + reinstallations_processed: int + preventative_maintenance_processed: int + + +@public_maintenance_router.get( + "/maintenance/home_summary", + tags=["Maintenance"], + response_model=HomeSummaryResponse, +) +def get_home_summary(db: Session = Depends(get_db)): + completed_work_orders = ( + db.query(func.count(workOrders.id)) + .join(workOrderStatusLU, workOrderStatusLU.id == workOrders.status_id) + .filter(workOrderStatusLU.name == "Closed") + .scalar() + or 0 + ) + + activity_counts = dict( + db.query(ActivityTypeLU.name, func.count(MeterActivities.id)) + .join(MeterActivities, MeterActivities.activity_type_id == ActivityTypeLU.id) + .filter( + ActivityTypeLU.name.in_( + ["Repair", "Re-install", "Preventative Maintenance"] + ) + ) + .group_by(ActivityTypeLU.name) + .all() + ) + + return { + "completed_work_orders": completed_work_orders, + "repairs_processed": activity_counts.get("Repair", 0), + "reinstallations_processed": activity_counts.get("Re-install", 0), + "preventative_maintenance_processed": activity_counts.get( + "Preventative Maintenance", 0 + ), + } + + +@authenticated_maintenance_router.get( "/maintenance", tags=["Maintenance"], response_model=MaintenanceSummaryResponse, @@ -171,7 +218,7 @@ def get_maintenance_summary( } -@maintenance_router.get( +@authenticated_maintenance_router.get( "/maintenance/pdf", tags=["Maintenance"], dependencies=[Depends(ScopedUser.Read)], diff --git a/api/routes/meters.py b/api/routes/meters.py index 9f78258d..889a2b00 100644 --- a/api/routes/meters.py +++ b/api/routes/meters.py @@ -12,6 +12,7 @@ Meters, LandOwners, MeterActivities, + PartsUsed, Parts, MeterObservations, Locations, @@ -36,6 +37,7 @@ PHOTO_JWT_EXPIRE_SECONDS = 600 # 10 minutes BUCKET_NAME = os.getenv("GCP_BUCKET_NAME", "") + # Get paginated, sorted list of meters, filtered by a search string if applicable @authenticated_meter_router.get( "/meters", @@ -46,7 +48,7 @@ def get_meters( # offset: int, limit: int - From fastapi_pagination search_string: str = None, - filter_by_status: List[MeterStatus] = Query('Installed'), + filter_by_status: List[MeterStatus] = Query("Installed"), sort_by: MeterSortByField = MeterSortByField.SerialNumber, sort_direction: SortDirection = SortDirection.Ascending, db: Session = Depends(get_db), @@ -64,14 +66,17 @@ def sort_by_field_to_schema_field(name: MeterSortByField): case MeterSortByField.TRSS: return Locations.trss - + # If 'Warehouse' is in the filter, add 'On Hold' to the filter - if MeterStatus.OnHold not in filter_by_status and MeterStatus.Warehouse in filter_by_status: + if ( + MeterStatus.OnHold not in filter_by_status + and MeterStatus.Warehouse in filter_by_status + ): filter_by_status.append(MeterStatus.OnHold) # Convert enums to strings filter_by_status_str = [status.value for status in filter_by_status] - + # Build the query statement based on query params # joinedload loads relationships, outer joins on relationship tables makes them search/sortable query_statement = ( @@ -99,9 +104,7 @@ def sort_by_field_to_schema_field(name: MeterSortByField): if sort_direction != SortDirection.Ascending: query_statement = query_statement.order_by(desc(schema_field_name)) else: - query_statement = query_statement.order_by( - schema_field_name - ) + query_statement = query_statement.order_by(schema_field_name) return paginate(db, query_statement) @@ -217,7 +220,7 @@ def get_meters_locations( if not meter_ids: return [] # Short-circuit if nothing matched - # Query latest PMs for those meters + # Query latest PMs for those meters pm_query = text( """ SELECT MAX(timestamp_start) AS last_pm, meter_id @@ -230,7 +233,7 @@ def get_meters_locations( pm_years = db.execute(pm_query, {"mids": meter_ids}).fetchall() pm_dict = {row.meter_id: row.last_pm for row in pm_years} - # Map to DTOs manually for added performance + # Map to DTOs manually for added performance meter_map_list = [] for row in result: meter_map_list.append( @@ -248,14 +251,13 @@ def get_meters_locations( "longitude": row.longitude, "trss": row.trss, }, - last_pm=pm_dict.get(row.id) + last_pm=pm_dict.get(row.id), ) ) return meter_map_list - def require_meter_id_or_serial_number(meter_id: int = None, serial_number: str = None): if not meter_id and not serial_number: raise HTTPException( @@ -264,6 +266,7 @@ def require_meter_id_or_serial_number(meter_id: int = None, serial_number: str = return meter_id, serial_number + # Get single, fully qualified meter # Can use either meter_id or serial_number @authenticated_meter_router.get( @@ -278,12 +281,12 @@ def get_meter( # Create the basic query query = select(Meters).options( - joinedload(Meters.meter_type), - joinedload(Meters.well).joinedload(Wells.location), - joinedload(Meters.status), - joinedload(Meters.meter_register).joinedload(meterRegisters.dial_units), - joinedload(Meters.meter_register).joinedload(meterRegisters.totalizer_units), - ) + joinedload(Meters.meter_type), + joinedload(Meters.well).joinedload(Wells.location), + joinedload(Meters.status), + joinedload(Meters.meter_register).joinedload(meterRegisters.dial_units), + joinedload(Meters.meter_register).joinedload(meterRegisters.totalizer_units), + ) # Filter by either meter by id or serial number if meter_id: @@ -314,13 +317,12 @@ def get_meter_types(db: Session = Depends(get_db)): def get_meter_registers(db: Session = Depends(get_db)): query = select(meterRegisters).options( joinedload(meterRegisters.dial_units), - joinedload(meterRegisters.totalizer_units) + joinedload(meterRegisters.totalizer_units), ) return db.scalars(query).all() - # A route to return status types from the MeterStatusLU table @authenticated_meter_router.get( "/meter_status_types", @@ -468,7 +470,9 @@ class HistoryType(Enum): joinedload(MeterActivities.location), joinedload(MeterActivities.submitting_user), joinedload(MeterActivities.activity_type), - joinedload(MeterActivities.parts_used).joinedload(Parts.part_type), + joinedload(MeterActivities.parts_used_links) + .joinedload(PartsUsed.part) + .joinedload(Parts.part_type), joinedload(MeterActivities.notes), joinedload(MeterActivities.services_performed), ) @@ -496,8 +500,10 @@ class HistoryType(Enum): for activity in activities: activity.location.geom = None # FastAPI errors when returning this - #Find if there is a well associated with the location - activity_well = db.scalars(select(Wells).where(Wells.location_id == activity.location_id)).first() + # Find if there is a well associated with the location + activity_well = db.scalars( + select(Wells).where(Wells.location_id == activity.location_id) + ).first() photos = [ { @@ -526,8 +532,10 @@ class HistoryType(Enum): for observation in observations: observation.location.geom = None - #Find if there is a well associated with the location - observation_well = db.scalars(select(Wells).where(Wells.location_id == observation.location_id)).first() + # Find if there is a well associated with the location + observation_well = db.scalars( + select(Wells).where(Wells.location_id == observation.location_id) + ).first() formattedHistoryItems.append( { @@ -556,7 +564,7 @@ def create_signed_url(blob_path: str) -> str: creds = impersonated_credentials.Credentials( source_credentials=source_creds, target_principal=target_sa, - target_scopes=['https://www.googleapis.com/auth/devstorage.read_only'], + target_scopes=["https://www.googleapis.com/auth/devstorage.read_only"], lifetime=3600, ) diff --git a/api/routes/notifications.py b/api/routes/notifications.py new file mode 100644 index 00000000..0efdad02 --- /dev/null +++ b/api/routes/notifications.py @@ -0,0 +1,198 @@ +from datetime import date, datetime, time + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi_pagination import LimitOffsetPage +from fastapi_pagination.ext.sqlalchemy import paginate +from sqlalchemy import func, select +from sqlalchemy.orm import Session, joinedload + +from api.enums import ScopedUser +from api.models.main_models import Notifications, NotificationTypeLU, Users +from api.schemas.notification_schemas import ( + NotificationCreateRequest, + NotificationCreateResult, + Notification, + NotificationReadUpdate, + NotificationType, + NotificationUnreadCount, +) +from api.security import get_current_user +from api.session import get_db + +notifications_router = APIRouter() + + +@notifications_router.get( + "/notifications", + dependencies=[Depends(ScopedUser.Read)], + response_model=LimitOffsetPage[Notification], + tags=["Notifications"], +) +def get_notifications( + q: str | None = None, + is_read: bool | None = None, + notification_type_id: list[int] | None = Query(None), + created_from: date | None = None, + created_to: date | None = None, + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + query_statement = ( + select(Notifications) + .options( + joinedload(Notifications.notification_type), + joinedload(Notifications.creator), + ) + .where(Notifications.user_id == user.id) + .order_by(Notifications.created_at.desc(), Notifications.id.desc()) + ) + + if q: + ilike_term = f"%{q.strip()}%" + query_statement = query_statement.where( + Notifications.title.ilike(ilike_term) + | Notifications.message.ilike(ilike_term) + | Notifications.link.ilike(ilike_term) + ) + + if is_read is not None: + query_statement = query_statement.where(Notifications.is_read == is_read) + + if notification_type_id: + query_statement = query_statement.where( + Notifications.notification_type_id.in_(notification_type_id) + ) + + if created_from is not None: + query_statement = query_statement.where( + Notifications.created_at >= datetime.combine(created_from, time.min) + ) + + if created_to is not None: + query_statement = query_statement.where( + Notifications.created_at <= datetime.combine(created_to, time.max) + ) + + return paginate(db, query_statement) + + +@notifications_router.get( + "/notification_types", + dependencies=[Depends(ScopedUser.Read)], + response_model=list[NotificationType], + tags=["Notifications"], +) +def get_notification_types(db: Session = Depends(get_db)): + return db.scalars( + select(NotificationTypeLU).order_by(func.lower(NotificationTypeLU.name)) + ).all() + + +@notifications_router.get( + "/notifications/unread_count", + dependencies=[Depends(ScopedUser.Read)], + response_model=NotificationUnreadCount, + tags=["Notifications"], +) +def get_unread_notification_count( + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + unread_count = db.scalar( + select(func.count(Notifications.id)).where( + Notifications.user_id == user.id, Notifications.is_read.is_(False) + ) + ) + + return {"unread_count": unread_count or 0} + + +@notifications_router.post( + "/notifications", + dependencies=[Depends(ScopedUser.Admin)], + response_model=NotificationCreateResult, + tags=["Notifications"], +) +def create_notifications( + payload: NotificationCreateRequest, + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + user_ids = set(payload.user_ids) + + if payload.role_ids: + role_user_ids = db.scalars( + select(Users.id).where( + Users.user_role_id.in_(payload.role_ids), Users.disabled.is_(False) + ) + ).all() + user_ids.update(role_user_ids) + + if user_ids: + valid_user_ids = db.scalars( + select(Users.id).where(Users.id.in_(user_ids), Users.disabled.is_(False)) + ).all() + user_ids = set(valid_user_ids) + + if not user_ids: + raise HTTPException( + status_code=400, + detail="At least one active user or role recipient is required", + ) + + notification_type_exists = db.scalar( + select(NotificationTypeLU.id).where( + NotificationTypeLU.id == payload.notification_type_id + ) + ) + if not notification_type_exists: + raise HTTPException(status_code=404, detail="Notification type not found") + + notifications = [ + Notifications( + user_id=user_id, + notification_type_id=payload.notification_type_id, + created_by=user.id, + title=payload.title.strip(), + message=payload.message.strip(), + link=payload.link.strip() if payload.link else None, + ) + for user_id in user_ids + ] + + db.add_all(notifications) + db.commit() + + return {"created_count": len(notifications)} + + +@notifications_router.patch( + "/notifications", + dependencies=[Depends(ScopedUser.Read)], + response_model=Notification, + tags=["Notifications"], +) +def update_notification_read_status( + payload: NotificationReadUpdate, + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + notification = db.scalar( + select(Notifications) + .options( + joinedload(Notifications.notification_type), + joinedload(Notifications.creator), + ) + .where(Notifications.id == payload.id, Notifications.user_id == user.id) + ) + + if not notification: + raise HTTPException(status_code=404, detail="Notification not found") + + notification.is_read = payload.is_read + notification.read_at = datetime.now() if payload.is_read else None + + db.commit() + db.refresh(notification) + + return notification diff --git a/api/routes/parts.py b/api/routes/parts.py index 42ece499..ca771877 100644 --- a/api/routes/parts.py +++ b/api/routes/parts.py @@ -1,21 +1,22 @@ from fastapi import Depends, APIRouter, HTTPException, Query -from sqlalchemy.orm import Session, joinedload -from sqlalchemy import select, func +from sqlalchemy.orm import Session, joinedload, selectinload +from sqlalchemy import select, func, literal, union_all from typing import List, Union, Optional -from datetime import datetime, date +from datetime import datetime, date, time from fastapi.responses import StreamingResponse from weasyprint import HTML from io import BytesIO from api.models.main_models import ( Parts, PartsUsed, + PartsAdded, PartAssociation, PartTypeLU, Meters, - MeterTypeLU, - meterRegisters, - MeterActivities, -) + MeterTypeLU, + meterRegisters, + MeterActivities, +) from api.schemas import part_schemas from api.session import get_db from api.route_util import _get @@ -26,12 +27,98 @@ TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" -templates = Environment( - loader=FileSystemLoader(TEMPLATES_DIR), - autoescape=select_autoescape(["html", "xml"]) -) - -part_router = APIRouter() +templates = Environment( + loader=FileSystemLoader(TEMPLATES_DIR), + autoescape=select_autoescape(["html", "xml"]), +) + +part_router = APIRouter() + + +def _build_part_history_response(part_id: int, db: Session) -> part_schemas.PartHistoryResponse: + part = db.scalars(select(Parts).where(Parts.id == part_id)).first() + if not part: + raise HTTPException(status_code=404, detail="Part not found") + + added_q = select( + PartsAdded.id.label("ref_id"), + PartsAdded.part_id.label("part_id"), + PartsAdded.date.label("event_date"), + literal("added").label("event_type"), + PartsAdded.note.label("note"), + PartsAdded.count.label("delta"), + literal(None).label("work_order_id"), + ).where(PartsAdded.part_id == part_id) + + used_q = ( + select( + PartsUsed.id.label("ref_id"), + PartsUsed.part_id.label("part_id"), + MeterActivities.timestamp_start.label("event_date"), + literal("used").label("event_type"), + func.nullif(func.trim(MeterActivities.description), "").label("note"), + (-PartsUsed.count).label("delta"), + MeterActivities.work_order_id.label("work_order_id"), + ) + .join(MeterActivities, MeterActivities.id == PartsUsed.meter_activity_id) + .where(PartsUsed.part_id == part_id) + ) + + events = union_all(added_q, used_q).subquery() + + rows = db.execute( + select( + events.c.ref_id, + events.c.part_id, + events.c.event_date, + events.c.event_type, + events.c.note, + events.c.delta, + events.c.work_order_id, + ).order_by(events.c.event_date.asc(), events.c.ref_id.asc()) + ).all() + + running = int(part.initial_count) + history: list[part_schemas.PartHistoryRow] = [ + part_schemas.PartHistoryRow( + row_id=f"initial-{part_id}", + part_id=part_id, + event_date=datetime.min, + event_type="initial", + ref_id=None, + note="Initial count", + delta=0, + total_after=running, + work_order_id=None, + ) + ] + + for ref_id, pid, event_date, event_type, note, delta, work_order_id in rows: + if not isinstance(event_date, datetime): + event_date = datetime.combine(event_date, time.min) + + running += int(delta) + history.append( + part_schemas.PartHistoryRow( + row_id=f"{event_type}-{ref_id}", + part_id=pid, + event_date=event_date, + event_type=event_type, + ref_id=ref_id, + note=note, + delta=int(delta), + total_after=running, + work_order_id=work_order_id, + ) + ) + + return part_schemas.PartHistoryResponse( + part_id=part.id, + part_number=part.part_number, + initial_count=part.initial_count, + current_count=running, + history=history, + ) @part_router.get( @@ -42,17 +129,50 @@ ) def get_parts( db: Session = Depends(get_db), - in_use: Optional[bool] = Query( - None, - description="Filter by in_use status" - ), + in_use: Optional[bool] = Query(None, description="Filter by in_use status"), ): - stmt = select(Parts).options(joinedload(Parts.part_type)) + used_subq = ( + select( + PartsUsed.part_id.label("part_id"), + func.coalesce(func.sum(PartsUsed.count), 0).label("used_sum"), + ) + .group_by(PartsUsed.part_id) + .subquery() + ) + + added_subq = ( + select( + PartsAdded.part_id.label("part_id"), + func.coalesce(func.sum(PartsAdded.count), 0).label("added_sum"), + ) + .group_by(PartsAdded.part_id) + .subquery() + ) + + current_count = ( + Parts.initial_count + + func.coalesce(added_subq.c.added_sum, 0) + - func.coalesce(used_subq.c.used_sum, 0) + ).label("current_count") + + stmt = ( + select(Parts, current_count) + .outerjoin(used_subq, used_subq.c.part_id == Parts.id) + .outerjoin(added_subq, added_subq.c.part_id == Parts.id) + .options(selectinload(Parts.part_type)) + ) if in_use is not None: stmt = stmt.where(Parts.in_use == in_use) - return db.scalars(stmt).all() + rows = db.execute(stmt).all() + + results = [] + for part, curr in rows: + part.current_count = curr + results.append(part) + + return results @part_router.get( @@ -72,19 +192,16 @@ def get_parts_used_summary( 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 + PartsUsed.part_id.label("used_part_id"), + func.coalesce(func.sum(PartsUsed.count), 0).label("quantity"), ) + .join(MeterActivities, MeterActivities.id == PartsUsed.meter_activity_id) .filter( MeterActivities.timestamp_start >= start_dt, MeterActivities.timestamp_start <= end_dt, - PartsUsed.c.part_id.in_(parts), + PartsUsed.part_id.in_(parts), ) - .group_by(PartsUsed.c.part_id) + .group_by(PartsUsed.part_id) .subquery() ) @@ -94,7 +211,7 @@ def get_parts_used_summary( Parts.part_number, Parts.description, Parts.price, - func.coalesce(usage_subq.c.quantity, 0).label("quantity") + 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)) @@ -103,17 +220,19 @@ def get_parts_used_summary( results = [] for row in query.all(): - price = row.price or 0 - quantity = row.quantity or 0 + price = float(row.price or 0) + quantity = int(row.quantity or 0) total = price * quantity - results.append({ - "id": row.id, - "part_number": row.part_number, - "description": row.description, - "price": price, - "quantity": quantity, - "total": total, - }) + results.append( + { + "id": row.id, + "part_number": row.part_number, + "description": row.description, + "price": price, + "quantity": quantity, + "total": total, + } + ) return results @@ -130,7 +249,9 @@ def download_parts_used_pdf( db: Session = Depends(get_db), ): # Re-use your existing logic - results = get_parts_used_summary(from_date=from_date, to_date=to_date, parts=parts, db=db) + results = get_parts_used_summary( + from_date=from_date, to_date=to_date, parts=parts, db=db + ) # Add running total just for PDF running_total = 0.0 @@ -151,9 +272,7 @@ def download_parts_used_pdf( return StreamingResponse( pdf_io, media_type="application/pdf", - headers={ - "Content-Disposition": "attachment; filename=parts_used_report.pdf" - }, + headers={"Content-Disposition": "attachment; filename=parts_used_report.pdf"}, ) @@ -174,33 +293,67 @@ def get_part_types(db: Session = Depends(get_db)): tags=["Parts"], ) def get_part(part_id: int, db: Session = Depends(get_db)): - selected_part = db.scalars( - select(Parts) + used_subq = ( + select( + PartsUsed.part_id.label("part_id"), + func.coalesce(func.sum(PartsUsed.count), 0).label("used_sum"), + ) + .group_by(PartsUsed.part_id) + .subquery() + ) + + added_subq = ( + select( + PartsAdded.part_id.label("part_id"), + func.coalesce(func.sum(PartsAdded.count), 0).label("added_sum"), + ) + .group_by(PartsAdded.part_id) + .subquery() + ) + + current_count = ( + Parts.initial_count + + func.coalesce(added_subq.c.added_sum, 0) + - func.coalesce(used_subq.c.used_sum, 0) + ).label("current_count") + + row = db.execute( + select(Parts, current_count) + .outerjoin(used_subq, used_subq.c.part_id == Parts.id) + .outerjoin(added_subq, added_subq.c.part_id == Parts.id) .where(Parts.id == part_id) .options( - joinedload(Parts.part_type), - joinedload(Parts.meter_types), + selectinload(Parts.part_type), + selectinload(Parts.meter_types), ) ).first() + if not row: + return None + + selected_part, curr = row + selected_part.current_count = curr + # Create the part_schemas.Part instance returned_part = part_schemas.Part.model_validate(selected_part) # If part_type is a Register, we need to load the register details if selected_part and selected_part.part_type.name == "Register": register_details = db.scalars( - select(meterRegisters).where( - meterRegisters.part_id == selected_part.id - ) + select(meterRegisters).where(meterRegisters.part_id == selected_part.id) ).first() - register_details = part_schemas.Register.register_details.model_validate(register_details) + register_details_obj = None + if register_details is not None: + register_details_obj = ( + part_schemas.Register.register_details.model_validate(register_details) + ) # Update the returned_part to include register details returned_part = part_schemas.Register( **returned_part.model_dump(exclude_unset=True), - register_settings=register_details - ) + register_settings=register_details_obj, + ) return returned_part @@ -214,8 +367,9 @@ def get_part(part_id: int, db: Session = Depends(get_db)): def update_part(updated_part: part_schemas.Part, db: Session = Depends(get_db)): # Update the part (this won't include secondary attributes like associations) part_db = _get(db, Parts, updated_part.id) + for k, v in updated_part.model_dump(exclude_unset=True).items(): - if k in ["part_type", "meter_types"]: + if k in ["part_type", "meter_types", "current_count"]: continue try: setattr(part_db, k, v) @@ -262,7 +416,7 @@ def create_part(new_part: part_schemas.Part, db: Session = Depends(get_db)): part_type_id=new_part.part_type_id, description=new_part.description, vendor=new_part.vendor, - count=new_part.count, + initial_count=new_part.initial_count, note=new_part.note, in_use=new_part.in_use, commonly_used=new_part.commonly_used, @@ -316,3 +470,159 @@ def get_meter_parts(meter_id: int, db: Session = Depends(get_db)): ).all() return meter_parts + + +@part_router.post( + "/parts/add", + response_model=part_schemas.Part, + dependencies=[Depends(ScopedUser.Admin)], + tags=["Parts"], +) +def add_parts(payload: part_schemas.PartsAddRequest, db: Session = Depends(get_db)): + # Ensure part exists + part = db.scalars(select(Parts).where(Parts.id == payload.part_id)).first() + if not part: + raise HTTPException(status_code=404, detail="Part not found") + + # Insert PartsAdded row (do NOT mutate Parts.initial_count) + added = PartsAdded( + part_id=payload.part_id, + count=payload.count, + date=payload.date, + note=payload.note, + ) + db.add(added) + db.commit() + + # Return updated part with current_count computed (same formula) + used_subq = ( + select( + PartsUsed.part_id.label("part_id"), + func.coalesce(func.sum(PartsUsed.count), 0).label("used_sum"), + ) + .group_by(PartsUsed.part_id) + .subquery() + ) + + added_subq = ( + select( + PartsAdded.part_id.label("part_id"), + func.coalesce(func.sum(PartsAdded.count), 0).label("added_sum"), + ) + .group_by(PartsAdded.part_id) + .subquery() + ) + + current_count = ( + Parts.initial_count + + func.coalesce(added_subq.c.added_sum, 0) + - func.coalesce(used_subq.c.used_sum, 0) + ).label("current_count") + + row = db.execute( + select(Parts, current_count) + .outerjoin(used_subq, used_subq.c.part_id == Parts.id) + .outerjoin(added_subq, added_subq.c.part_id == Parts.id) + .where(Parts.id == payload.part_id) + .options(selectinload(Parts.part_type), selectinload(Parts.meter_types)) + ).first() + + if not row: + raise HTTPException(status_code=404, detail="Part not found") + + part_obj, curr = row + part_obj.current_count = curr + return part_obj + + +@part_router.get( + "/parts/{part_id}/history", + response_model=part_schemas.PartHistoryResponse, + dependencies=[Depends(ScopedUser.Admin)], + tags=["Parts"], +) +def get_part_history(part_id: int, db: Session = Depends(get_db)): + return _build_part_history_response(part_id, db) + + +@part_router.patch( + "/parts/{part_id}/history", + response_model=part_schemas.PartHistoryResponse, + dependencies=[Depends(ScopedUser.Admin)], + tags=["Parts"], +) +def patch_part_history( + part_id: int, + payload: part_schemas.PartHistoryUpdateRequest, + db: Session = Depends(get_db), +): + part = db.scalars(select(Parts).where(Parts.id == part_id)).first() + if not part: + raise HTTPException(status_code=404, detail="Part not found") + + for row in payload.rows: + normalized_note = row.note.strip() if row.note else None + if normalized_note == "": + normalized_note = None + + if row.event_type == "added": + if row.delta <= 0: + raise HTTPException( + status_code=422, + detail="Added parts rows must have a positive change.", + ) + + added_row = db.scalars( + select(PartsAdded).where( + PartsAdded.id == row.ref_id, + PartsAdded.part_id == part_id, + ) + ).first() + if not added_row: + raise HTTPException(status_code=404, detail="Parts added row not found.") + + added_row.count = row.delta + added_row.date = row.event_date.date() + added_row.note = normalized_note + continue + + if row.delta >= 0: + raise HTTPException( + status_code=422, + detail="Work order rows must have a negative change.", + ) + + parts_used_row = db.scalars( + select(PartsUsed).where( + PartsUsed.id == row.ref_id, + PartsUsed.part_id == part_id, + ) + ).first() + if not parts_used_row: + raise HTTPException(status_code=404, detail="Parts used row not found.") + + activity = db.scalars( + select(MeterActivities).where( + MeterActivities.id == parts_used_row.meter_activity_id + ) + ).first() + if not activity: + raise HTTPException( + status_code=404, + detail="Meter activity for parts used row not found.", + ) + + original_start = activity.timestamp_start + original_end = activity.timestamp_end + duration = original_end - original_start if original_end and original_start else None + + parts_used_row.count = abs(row.delta) + activity.timestamp_start = row.event_date + activity.description = normalized_note + if duration is not None: + activity.timestamp_end = row.event_date + duration + else: + activity.timestamp_end = row.event_date + + db.commit() + return _build_part_history_response(part_id, db) diff --git a/api/routes/settings.py b/api/routes/settings.py index 4e94ae38..90c51e15 100644 --- a/api/routes/settings.py +++ b/api/routes/settings.py @@ -1,12 +1,25 @@ -from fastapi import Depends, APIRouter, HTTPException +from base64 import b64encode +from io import BytesIO + +from fastapi import Depends, APIRouter, HTTPException, File, UploadFile +from PIL import Image, UnidentifiedImageError +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from api.schemas.base import ORMBase from api.session import get_db -from api.security import get_current_user +from api.security import get_current_user, get_password_hash, verify_password from api.models.main_models import Users settings_router = APIRouter() +MAX_AVATAR_FILE_SIZE_BYTES = 5 * 1024 * 1024 +MAX_AVATAR_PIXELS = 4096 * 4096 +ALLOWED_AVATAR_FORMATS = { + "JPEG": "image/jpeg", + "PNG": "image/png", + "WEBP": "image/webp", + "GIF": "image/gif", +} @settings_router.get( @@ -52,6 +65,11 @@ class DisplayNameUpdate(ORMBase): display_name: str +class PasswordResetRequest(ORMBase): + current_password: str + new_password: str + + @settings_router.post( "/settings/display_name", tags=["settings"], @@ -70,3 +88,128 @@ def post_redirect_page( db.refresh(db_user) return {"message": "Display name updated", "display_name": db_user.display_name} + + +@settings_router.post( + "/settings/password_reset", + tags=["settings"], +) +def post_password_reset( + update: PasswordResetRequest, + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + db_user = db.query(Users).filter(Users.id == user.id).first() + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + + if not verify_password(update.current_password, db_user.hashed_password): + raise HTTPException(status_code=400, detail="Current password is incorrect") + + if update.current_password == update.new_password: + raise HTTPException( + status_code=400, + detail="New password must be different from current password", + ) + + if len(update.new_password) < 8: + raise HTTPException( + status_code=400, + detail="New password must be at least 8 characters long", + ) + + db_user.hashed_password = get_password_hash(update.new_password) + + try: + db.commit() + db.refresh(db_user) + except SQLAlchemyError: + db.rollback() + raise HTTPException(status_code=500, detail="Failed to update password") + + return {"message": "Password updated"} + + +@settings_router.post( + "/settings/avatar", + tags=["settings"], +) +async def post_avatar( + avatar: UploadFile = File(...), + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + db_user = db.query(Users).filter(Users.id == user.id).first() + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + + avatar_bytes = await avatar.read() + + if not avatar_bytes: + raise HTTPException(status_code=400, detail="Avatar image is required") + + if len(avatar_bytes) > MAX_AVATAR_FILE_SIZE_BYTES: + raise HTTPException( + status_code=413, + detail=f"Avatar image exceeds {MAX_AVATAR_FILE_SIZE_BYTES // (1024 * 1024)} MB limit", + ) + + try: + with Image.open(BytesIO(avatar_bytes)) as image: + width, height = image.size + if width * height > MAX_AVATAR_PIXELS: + raise HTTPException(status_code=400, detail="Avatar image is too large") + + image.verify() + + with Image.open(BytesIO(avatar_bytes)) as image: + image_format = image.format + + except HTTPException: + raise + except (UnidentifiedImageError, Image.DecompressionBombError, OSError): + raise HTTPException(status_code=400, detail="Uploaded file is not a valid image") + + if image_format not in ALLOWED_AVATAR_FORMATS: + raise HTTPException( + status_code=400, + detail="Avatar image must be a JPEG, PNG, WEBP, or GIF file", + ) + + db_user.avatar_img = ( + f"data:{ALLOWED_AVATAR_FORMATS[image_format]};base64," + f"{b64encode(avatar_bytes).decode('ascii')}" + ) + + try: + db.commit() + db.refresh(db_user) + except SQLAlchemyError: + db.rollback() + raise HTTPException(status_code=500, detail="Failed to update avatar image") + + return {"message": "Avatar updated", "avatar_img": db_user.avatar_img} + + +@settings_router.delete( + "/settings/avatar", + tags=["settings"], +) +def delete_avatar( + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + db_user = db.query(Users).filter(Users.id == user.id).first() + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + + db_user.avatar_img = None + + try: + db.commit() + db.refresh(db_user) + except SQLAlchemyError: + db.rollback() + raise HTTPException(status_code=500, detail="Failed to clear avatar image") + + return {"message": "Avatar cleared", "avatar_img": db_user.avatar_img} diff --git a/api/routes/well_measurements.py b/api/routes/well_measurements.py index 4513fd1e..3a7704dc 100644 --- a/api/routes/well_measurements.py +++ b/api/routes/well_measurements.py @@ -1,11 +1,11 @@ -from typing import List, Optional +from typing import List, Optional, Any, Dict from datetime import datetime, date import re from fastapi import Depends, APIRouter, Query, HTTPException from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session, joinedload -from sqlalchemy import select, and_ +from sqlalchemy import select, and_, func from weasyprint import HTML from io import BytesIO @@ -336,6 +336,43 @@ def add_year_average(year: int, label: str): return response_data +@public_well_measurement_router.get( + "/waterlevels/report-averages", + tags=["WaterLevels"], +) +def read_waterlevel_report_averages( + well_ids: List[int] = Query(..., description="One or more well IDs"), + 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)" + ), + db: Session = Depends(get_db), +): + """ + Report aggregates: + - per-well average depth-to-water for the derived bucket (month or year) + - all-wells average depth-to-water for the derived bucket (month or year) + + Bucket is derived from range: + >= 365 days => year buckets + else => month buckets + """ + + if from_date is None and to_date is None: + raise HTTPException( + status_code=400, detail="from_date and/or to_date is required for reports" + ) + + return get_waterlevel_report_averages( + well_ids=well_ids, + from_date=from_date, + to_date=to_date, + db=db, + ) + + @authenticated_well_measurement_router.get( "/waterlevels/pdf", dependencies=[Depends(ScopedUser.Read)], @@ -460,6 +497,13 @@ def make_line_chart(data: dict, title: str): "ON OR NEAR THE 5TH, 15TH AND 25TH OF EACH MONTH" ) + averages = get_waterlevel_report_averages( + well_ids=well_ids, + from_date=from_date, + to_date=to_date, + db=db, + ) + html = templates.get_template("waterlevels_report.html").render( from_date=from_date, to_date=to_date, @@ -467,6 +511,7 @@ def make_line_chart(data: dict, title: str): rows=rows, report_title=report_title, report_subtext=report_subtext, + averages=averages, ) pdf_io = BytesIO() @@ -521,3 +566,104 @@ def delete_waterlevel(waterlevel_id: int, db: Session = Depends(get_db)): db.commit() return True + + +def get_waterlevel_report_averages( + *, + well_ids: List[int], + from_date: Optional[date], + to_date: Optional[date], + db: Session, +) -> Dict[str, Any]: + """ + Shared logic used by both JSON endpoint and PDF endpoint. + Returns: + { + "bucket": "month" | "year", + "per_well": [ { well_id, ra_number, period_start, avg_value }, ...], + "all_wells": [ { period_start, avg_value }, ...], + } + """ + DEPTH_TO_WATER_NAME = "Depth to water" + + if not well_ids: + return {"bucket": None, "per_well": [], "all_wells": []} + + if from_date is None and to_date is None: + # Let callers decide whether to raise; for PDF we always have both. + return {"bucket": None, "per_well": [], "all_wells": []} + + start_dt = datetime.combine(from_date, datetime.min.time()) if from_date else None + end_dt = datetime.combine(to_date, datetime.max.time()) if to_date else None + + if from_date and to_date: + delta_days = (to_date - from_date).days + bucket_unit = "year" if delta_days >= 365 else "month" + else: + bucket_unit = "month" + + bucket = func.date_trunc(bucket_unit, WellMeasurements.timestamp).label( + "period_start" + ) + + base_filters = [ + ObservedPropertyTypeLU.name == DEPTH_TO_WATER_NAME, + WellMeasurements.well_id.in_(well_ids), + ] + if start_dt: + base_filters.append(WellMeasurements.timestamp >= start_dt) + if end_dt: + base_filters.append(WellMeasurements.timestamp <= end_dt) + + per_well_stmt = ( + select( + WellMeasurements.well_id.label("well_id"), + Wells.ra_number.label("ra_number"), + bucket, + func.avg(WellMeasurements.value).label("avg_value"), + ) + .join(Wells, Wells.id == WellMeasurements.well_id) + .join( + ObservedPropertyTypeLU, + ObservedPropertyTypeLU.id == WellMeasurements.observed_property_id, + ) + .where(and_(*base_filters)) + .group_by(WellMeasurements.well_id, Wells.ra_number, bucket) + .order_by(Wells.ra_number, bucket) + ) + per_well_rows = db.execute(per_well_stmt).all() + + all_wells_stmt = ( + select( + bucket, + func.avg(WellMeasurements.value).label("avg_value"), + ) + .join( + ObservedPropertyTypeLU, + ObservedPropertyTypeLU.id == WellMeasurements.observed_property_id, + ) + .where(and_(*base_filters)) + .group_by(bucket) + .order_by(bucket) + ) + all_wells_rows = db.execute(all_wells_stmt).all() + + return { + "bucket": bucket_unit, + "per_well": [ + { + "well_id": r.well_id, + "ra_number": r.ra_number, + "period_start": r.period_start, + "avg_value": float(r.avg_value) if r.avg_value is not None else None, + } + for r in per_well_rows + ], + "all_wells": [ + { + "period_start": r.period_start, + "avg_value": float(r.avg_value) if r.avg_value is not None else None, + } + for r in all_wells_rows + ], + } diff --git a/api/routes/wells.py b/api/routes/wells.py index 91dfe8c3..47613e56 100644 --- a/api/routes/wells.py +++ b/api/routes/wells.py @@ -55,6 +55,28 @@ def get_well_status_types( return db.scalars(select(WellStatus)).all() +@public_well_router.get( + "/wells/{well_id}", + response_model=well_schemas.WellResponse, + tags=["Wells"], +) +def get_well_by_id(well_id: int, db: Session = Depends(get_db)): + stmt = ( + select(Wells) + .options( + joinedload(Wells.location), + joinedload(Wells.use_type), + joinedload(Wells.meters), + joinedload(Wells.well_status), + ) + .where(Wells.id == well_id) + ) + well = db.scalars(stmt).first() + if not well: + raise HTTPException(status_code=404, detail="Well not found") + return well + + @public_well_router.get( "/wells", response_model=LimitOffsetPage[well_schemas.WellResponse], diff --git a/api/schemas/notification_schemas.py b/api/schemas/notification_schemas.py new file mode 100644 index 00000000..c5a35ef0 --- /dev/null +++ b/api/schemas/notification_schemas.py @@ -0,0 +1,45 @@ +from datetime import datetime + +from api.schemas.base import ORMBase +from api.schemas.security_schemas import User + + +class NotificationType(ORMBase): + name: str + description: str | None = None + + +class Notification(ORMBase): + user_id: int + notification_type_id: int + created_by: int | None = None + title: str + message: str + link: str | None = None + is_read: bool + created_at: datetime + read_at: datetime | None = None + notification_type: NotificationType + creator: User | None = None + + +class NotificationUnreadCount(ORMBase): + unread_count: int + + +class NotificationCreateRequest(ORMBase): + role_ids: list[int] = [] + user_ids: list[int] = [] + notification_type_id: int + title: str + message: str + link: str | None = None + + +class NotificationCreateResult(ORMBase): + created_count: int + + +class NotificationReadUpdate(ORMBase): + id: int + is_read: bool diff --git a/api/schemas/part_schemas.py b/api/schemas/part_schemas.py index 1f147c1a..deca5789 100644 --- a/api/schemas/part_schemas.py +++ b/api/schemas/part_schemas.py @@ -1,3 +1,5 @@ +from typing import List, Literal, Optional +from datetime import date, datetime from api.schemas.base import ORMBase from api.schemas.meter_schemas import MeterTypeLU @@ -11,7 +13,10 @@ class Part(ORMBase): part_number: str description: str | None = None vendor: str | None = None - count: int + + initial_count: int + current_count: Optional[int] = None + note: str | None = None in_use: bool commonly_used: bool @@ -21,12 +26,14 @@ class Part(ORMBase): part_type: PartTypeLU | None = None meter_types: list[MeterTypeLU] | None = None + class Register(Part): - ''' + """ Adds on register specific fields to the Part model. Note: There is also a MeterRegister schema that is used on the Meters view. I might want to merge these two in the future, but for now they are separate. - ''' + """ + class register_details(ORMBase): brand: str meter_size: float @@ -36,9 +43,48 @@ class register_details(ORMBase): number_of_digits: int | None = None multiplier: float | None = None - register_settings: register_details + register_settings: register_details | None = None class PartUsed(ORMBase): part_id: int meter_id: int + + +class PartsAddRequest(ORMBase): + part_id: int + count: int + date: date + note: Optional[str] = None + + +class PartHistoryRow(ORMBase): + row_id: str + part_id: int + event_date: datetime + event_type: Literal["initial", "added", "used"] + ref_id: int | None = None + work_order_id: int | None = None + note: str | None = None + delta: int + total_after: int + + +class PartHistoryUpdateRow(ORMBase): + ref_id: int + event_date: datetime + event_type: Literal["added", "used"] + note: str | None = None + delta: int + + +class PartHistoryUpdateRequest(ORMBase): + rows: List[PartHistoryUpdateRow] + + +class PartHistoryResponse(ORMBase): + part_id: int + part_number: str + initial_count: int + current_count: int + history: List[PartHistoryRow] diff --git a/api/security.py b/api/security.py index cf45b50a..ddb0fb2d 100644 --- a/api/security.py +++ b/api/security.py @@ -6,6 +6,7 @@ from jose import jwt, ExpiredSignatureError from passlib.context import CryptContext from starlette import status +from sqlalchemy import or_ from sqlalchemy.orm import joinedload, undefer, Session from sqlalchemy.sql import select @@ -40,8 +41,8 @@ # Return the current user if credentials were correct, False if not -def authenticate_user(username: str, password: str, db: Session): - user = get_user(username, db) +def authenticate_user(login_identifier: str, password: str, db: Session): + user = get_user_by_login(login_identifier, db) if not user: return False if not verify_password(password, user.hashed_password): @@ -72,9 +73,8 @@ def get_password_hash(password): return pwd_context.hash(password) -def get_user(username: str, db: Session) -> Users: - # Load User with all security scopes - user_stmt = ( +def get_user_query(): + return ( select(Users) .options( undefer(Users.hashed_password), @@ -83,8 +83,22 @@ def get_user(username: str, db: Session) -> Users: undefer(Users.email), joinedload(Users.user_role).joinedload(UserRoles.security_scopes), ) - .filter(Users.username == username) ) + + +def get_user_by_login(login_identifier: str, db: Session) -> Users: + # Allow login via either username or email. + user_stmt = get_user_query().filter( + or_(Users.username == login_identifier, Users.email == login_identifier) + ) + dbuser = db.scalars(user_stmt).first() + + if dbuser: + return dbuser + + +def get_user(username: str, db: Session) -> Users: + user_stmt = get_user_query().filter(Users.username == username) dbuser = db.scalars(user_stmt).first() if dbuser: diff --git a/api/session.py b/api/session.py index dbf1df85..fb3c20d8 100644 --- a/api/session.py +++ b/api/session.py @@ -1,18 +1,3 @@ -# =============================================================================== -# Copyright 2022 ross -# -# 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 sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker @@ -21,14 +6,6 @@ SQLALCHEMY_DATABASE_URL = settings.DATABASE_URL engine = create_engine(SQLALCHEMY_DATABASE_URL) -# if you don't want to install postgres or any database, use sqlite, a file system based database, -# uncomment below lines if you would like to use sqlite and comment above 2 lines of SQLALCHEMY_DATABASE_URL AND engine - -# SQLALCHEMY_DATABASE_URL = "sqlite:///./sql_app.db" -# engine = create_engine( -# SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} -# ) -print(SQLALCHEMY_DATABASE_URL) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) @@ -38,6 +15,3 @@ def get_db(): yield db finally: db.close() - - -# ============= EOF ============================================= diff --git a/api/templates/waterlevels_report.html b/api/templates/waterlevels_report.html index 3a70d8b0..aef1a88b 100644 --- a/api/templates/waterlevels_report.html +++ b/api/templates/waterlevels_report.html @@ -1,85 +1,138 @@ +
+ + - .chart { - margin-top: 2em; - text-align: center; - } - - + ++ {{ report_subtext }} +
+ {% endif %} - {% if report_subtext %} -- {{ report_subtext }} -
- {% endif %} ++ From: {{ from_date }} + To: {{ to_date }} +
-- From: {{ from_date }} - To: {{ to_date }} -
+ {% if observation_chart %} +| Well | +Date / Time | +Depth to Water (ft) | +
|---|---|---|
| {{ row.well_ra_number }} | +{{ row.timestamp }} | +{{ "%.2f"|format(row.depth_to_water) }} | +
| Date / Time | -Depth to Water (ft) | -Well | -
|---|---|---|
| {{ row.timestamp }} | -{{ row.depth_to_water }} | -{{ row.well_ra_number }} | -
| Well | +Period | +Average Depth to Water (ft) | +
|---|---|---|
| {{ row.ra_number }} | +{{ row.period_start }} | ++ {{ "%.2f"|format(row.avg_value) if row.avg_value is not none else "" + }} + | +
| Period | +Average Depth to Water (ft) | +
|---|---|
| {{ row.period_start }} | ++ {{ "%.2f"|format(row.avg_value) if row.avg_value is not none else "" + }} + | +