diff --git a/.github/workflows/deploy-to-gcp.yml b/.github/workflows/deploy-to-gcp.yml
index 13f9d838..8d410052 100644
--- a/.github/workflows/deploy-to-gcp.yml
+++ b/.github/workflows/deploy-to-gcp.yml
@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Set environment
run: |
diff --git a/.gitignore b/.gitignore
index f768459b..92cbdfe3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -229,3 +229,7 @@ cython_debug/
# dependencies
/node_modules
/frontend/node_modules
+
+# Mise Envs
+mise.toml
+
diff --git a/README.md b/README.md
index 35bacfb3..ae9ada95 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,26 @@
---
+### Password Security
+
+New and changed passwords must be at least 12 characters and include lowercase
+letters, uppercase letters, numbers, and symbols. Passwords that include obvious
+account identifiers such as the username, email, full name, or display name are
+rejected.
+
+Existing weak passwords do not block users from signing in. After a successful
+sign-in, the application records the current password's last known strength
+status so the Settings page can warn the user if their current password is weak.
+The Settings password section also shows when the password was last changed.
+
+When users type a new password, the UI shows strength feedback immediately. On
+blur, the backend checks the candidate password against the Have I Been Pwned
+Pwned Passwords range API using k-anonymity: only the first five characters of a
+SHA-1 hash are sent, and the raw password is never sent to the third-party
+service. Known compromised passwords are rejected when a password is changed.
+
+---
+
### Tech Stack
| Layer | Technology |
diff --git a/api/auth/dependencies.py b/api/auth/dependencies.py
index a0848c83..8acbc863 100644
--- a/api/auth/dependencies.py
+++ b/api/auth/dependencies.py
@@ -11,3 +11,6 @@ class ScopedUser(Enum):
WellMeasurementWrite = scoped_user(["well_measurement:write"])
MeterWrite = scoped_user(["meters:write"])
WellWrite = scoped_user(["well:write"])
+ WorkOrderRead = scoped_user(["work_orders:read"])
+ WorkOrderCreate = scoped_user(["work_orders:create"])
+ WorkOrderUpdate = scoped_user(["work_orders:update"])
diff --git a/api/auth/password_policy.py b/api/auth/password_policy.py
new file mode 100644
index 00000000..22409f3c
--- /dev/null
+++ b/api/auth/password_policy.py
@@ -0,0 +1,163 @@
+from __future__ import annotations
+
+import hashlib
+import re
+import urllib.error
+import urllib.request
+from dataclasses import dataclass
+from datetime import datetime, timezone
+
+from api.models.user import Users
+
+
+MIN_PASSWORD_LENGTH = 12
+HIBP_RANGE_API_URL = "https://api.pwnedpasswords.com/range"
+HIBP_USER_AGENT = "WaterManagerDB password checker"
+HIBP_TIMEOUT_SECONDS = 3
+
+
+@dataclass(frozen=True)
+class PasswordEvaluation:
+ score: int
+ label: str
+ is_policy_compliant: bool
+ missing_requirements: list[str]
+ compromised_count: int | None = None
+ compromised_checked_at: datetime | None = None
+ compromised_check_error: str | None = None
+
+
+def evaluate_password_strength(
+ password: str,
+ user: Users | None = None,
+ compromised_count: int | None = None,
+ compromised_checked_at: datetime | None = None,
+ compromised_check_error: str | None = None,
+) -> PasswordEvaluation:
+ missing_requirements = []
+
+ if len(password) < MIN_PASSWORD_LENGTH:
+ missing_requirements.append(
+ f"Use at least {MIN_PASSWORD_LENGTH} characters."
+ )
+ if not re.search(r"[a-z]", password):
+ missing_requirements.append("Add a lowercase letter.")
+ if not re.search(r"[A-Z]", password):
+ missing_requirements.append("Add an uppercase letter.")
+ if not re.search(r"\d", password):
+ missing_requirements.append("Add a number.")
+ if not re.search(r"[^A-Za-z0-9]", password):
+ missing_requirements.append("Add a symbol.")
+
+ lower_password = password.lower()
+ for value, label in _user_identifiers(user):
+ if value and len(value) >= 3 and value.lower() in lower_password:
+ missing_requirements.append(f"Do not include your {label}.")
+
+ score = 0
+ score += min(len(password), 16) // 4
+ score += 1 if re.search(r"[a-z]", password) else 0
+ score += 1 if re.search(r"[A-Z]", password) else 0
+ score += 1 if re.search(r"\d", password) else 0
+ score += 1 if re.search(r"[^A-Za-z0-9]", password) else 0
+ score += 1 if len(password) >= 16 else 0
+ score = min(score, 5)
+
+ if missing_requirements:
+ score = min(score, 2)
+
+ label = "Weak"
+ if score >= 5:
+ label = "Strong"
+ elif score >= 3:
+ label = "Moderate"
+
+ return PasswordEvaluation(
+ score=score,
+ label=label,
+ is_policy_compliant=not missing_requirements,
+ missing_requirements=missing_requirements,
+ compromised_count=compromised_count,
+ compromised_checked_at=compromised_checked_at,
+ compromised_check_error=compromised_check_error,
+ )
+
+
+def check_pwned_password(password: str) -> tuple[int | None, datetime, str | None]:
+ checked_at = datetime.now(timezone.utc)
+ password_hash = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
+ prefix = password_hash[:5]
+ suffix = password_hash[5:]
+ request = urllib.request.Request(
+ f"{HIBP_RANGE_API_URL}/{prefix}",
+ headers={
+ "Add-Padding": "true",
+ "User-Agent": HIBP_USER_AGENT,
+ },
+ )
+
+ try:
+ with urllib.request.urlopen(request, timeout=HIBP_TIMEOUT_SECONDS) as response:
+ body = response.read().decode("utf-8")
+ except (OSError, urllib.error.URLError, TimeoutError) as exc:
+ return None, checked_at, str(exc)
+
+ for line in body.splitlines():
+ found_suffix, _, count = line.partition(":")
+ if found_suffix == suffix:
+ try:
+ return int(count), checked_at, None
+ except ValueError:
+ return None, checked_at, "Unexpected compromised password response."
+
+ return 0, checked_at, None
+
+
+def evaluate_password(
+ password: str,
+ user: Users | None = None,
+ include_compromised_check: bool = False,
+) -> PasswordEvaluation:
+ compromised_count = None
+ compromised_checked_at = None
+ compromised_check_error = None
+
+ if include_compromised_check:
+ compromised_count, compromised_checked_at, compromised_check_error = (
+ check_pwned_password(password)
+ )
+
+ return evaluate_password_strength(
+ password=password,
+ user=user,
+ compromised_count=compromised_count,
+ compromised_checked_at=compromised_checked_at,
+ compromised_check_error=compromised_check_error,
+ )
+
+
+def apply_password_evaluation(user: Users, evaluation: PasswordEvaluation) -> None:
+ user.password_strength_score = evaluation.score
+ user.password_strength_label = evaluation.label
+ user.password_policy_compliant = evaluation.is_policy_compliant
+
+ if evaluation.compromised_checked_at is not None:
+ user.password_compromised_checked_at = evaluation.compromised_checked_at
+ user.password_compromised_count = evaluation.compromised_count
+
+
+def _user_identifiers(user: Users | None) -> list[tuple[str | None, str]]:
+ if user is None:
+ return []
+
+ values: list[tuple[str | None, str]] = [
+ (user.username, "username"),
+ (user.email, "email"),
+ (user.full_name, "name"),
+ (user.display_name, "display name"),
+ ]
+
+ if user.email and "@" in user.email:
+ values.append((user.email.split("@", 1)[0], "email"))
+
+ return values
diff --git a/api/auth/session_tracking.py b/api/auth/session_tracking.py
index 92bbf8b6..ab8eb299 100644
--- a/api/auth/session_tracking.py
+++ b/api/auth/session_tracking.py
@@ -5,8 +5,10 @@
from uuid import uuid4
from fastapi import Request
+from sqlalchemy import or_
from sqlalchemy.orm import Session
+from api.config import settings
from api.models.user import SignOutReasonTypeLU, UserSessions, Users
LAST_SEEN_UPDATE_INTERVAL = timedelta(minutes=5)
@@ -105,6 +107,87 @@ def build_device_label(
return browser or operating_system or device_type
+def close_user_session(
+ db: Session,
+ session: UserSessions,
+ reason_name: Optional[str],
+ signed_out_at: datetime,
+) -> UserSessions:
+ if session.signed_out_at is not None:
+ return session
+
+ sign_out_reason = get_sign_out_reason(db, reason_name)
+
+ session.signed_out_at = signed_out_at
+ session.last_seen_at = signed_out_at
+ session.is_active = False
+ session.sign_out_reason_type_id = sign_out_reason.id if sign_out_reason else None
+ db.add(session)
+
+ return session
+
+
+def close_old_active_user_sessions(
+ db: Session, user: Users, signed_out_at: datetime
+) -> None:
+ retention_days = max(settings.USER_SESSION_RETENTION_DAYS, 0)
+ cutoff = signed_out_at - timedelta(days=retention_days)
+ old_sessions = (
+ db.query(UserSessions)
+ .filter(
+ UserSessions.user_id == user.id,
+ UserSessions.is_active.is_(True),
+ UserSessions.signed_out_at.is_(None),
+ or_(
+ UserSessions.signed_in_at < cutoff,
+ UserSessions.last_seen_at < cutoff,
+ ),
+ )
+ .all()
+ )
+
+ for session in old_sessions:
+ close_user_session(db, session, "session_expired", signed_out_at)
+
+
+def close_existing_machine_session(
+ db: Session,
+ user: Users,
+ signed_out_at: datetime,
+ user_agent: Optional[str],
+ device_label: Optional[str],
+ device_type: Optional[str],
+ browser: Optional[str],
+ operating_system: Optional[str],
+ fingerprint_hash: Optional[str],
+) -> None:
+ query = db.query(UserSessions).filter(
+ UserSessions.user_id == user.id,
+ UserSessions.is_active.is_(True),
+ UserSessions.signed_out_at.is_(None),
+ )
+
+ if fingerprint_hash:
+ matching_sessions = query.filter(
+ UserSessions.fingerprint_hash == fingerprint_hash
+ ).all()
+ else:
+ if not any([user_agent, device_label, device_type, browser, operating_system]):
+ return
+
+ matching_sessions = query.filter(
+ UserSessions.fingerprint_hash.is_(None),
+ UserSessions.user_agent == user_agent,
+ UserSessions.device_label == device_label,
+ UserSessions.device_type == device_type,
+ UserSessions.browser == browser,
+ UserSessions.operating_system == operating_system,
+ ).all()
+
+ for session in matching_sessions:
+ close_user_session(db, session, "forced_logout", signed_out_at)
+
+
def create_user_session(db: Session, user: Users, request: Request) -> UserSessions:
user_agent = normalize_header_value(request.headers.get("user-agent"))
browser = normalize_header_value(request.headers.get("x-browser")) or parse_browser(
@@ -122,6 +205,20 @@ def create_user_session(db: Session, user: Users, request: Request) -> UserSessi
fingerprint_hash = normalize_header_value(
request.headers.get("x-device-fingerprint")
)
+ now = datetime.utcnow()
+
+ close_old_active_user_sessions(db=db, user=user, signed_out_at=now)
+ close_existing_machine_session(
+ db=db,
+ user=user,
+ signed_out_at=now,
+ user_agent=user_agent,
+ device_label=device_label,
+ device_type=device_type,
+ browser=browser,
+ operating_system=operating_system,
+ fingerprint_hash=fingerprint_hash,
+ )
session = UserSessions(
user_id=user.id,
@@ -133,8 +230,8 @@ def create_user_session(db: Session, user: Users, request: Request) -> UserSessi
browser=browser,
operating_system=operating_system,
fingerprint_hash=fingerprint_hash,
- signed_in_at=datetime.utcnow(),
- last_seen_at=datetime.utcnow(),
+ signed_in_at=now,
+ last_seen_at=now,
is_active=True,
)
@@ -188,15 +285,7 @@ def mark_session_signed_out(
if session.signed_out_at is not None:
return session
- sign_out_reason = get_sign_out_reason(db, reason_name)
-
- session.signed_out_at = datetime.utcnow()
- session.last_seen_at = session.signed_out_at
- session.is_active = False
- session.sign_out_reason_type_id = sign_out_reason.id if sign_out_reason else None
- db.add(session)
-
- return session
+ return close_user_session(db, session, reason_name, datetime.utcnow())
def touch_user_session(db: Session, session_identifier: Optional[str]) -> None:
diff --git a/api/config.py b/api/config.py
index 4c1f1890..a6309493 100644
--- a/api/config.py
+++ b/api/config.py
@@ -30,6 +30,9 @@ class Settings:
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"))
+ USER_SESSION_RETENTION_DAYS: int = int(
+ os.getenv("USER_SESSION_RETENTION_DAYS", "14")
+ )
APP_ENV: str = os.getenv(
"APP_ENV",
"production" if os.getenv("APPDB_ENV") == ".env_production" else "development",
diff --git a/api/main.py b/api/main.py
index facd1a99..a2668964 100644
--- a/api/main.py
+++ b/api/main.py
@@ -26,6 +26,7 @@
)
from api.routes.wells import authenticated_well_router, public_well_router
from api.auth.session_tracking import create_user_session, touch_user_session
+from api.auth.password_policy import apply_password_evaluation, evaluate_password
from api.security import (
authenticate_user,
create_access_token,
@@ -108,6 +109,13 @@ def login_for_access_token(
headers={"WWW-Authenticate": "Bearer"},
)
+ password_evaluation = evaluate_password(
+ form_data.password,
+ user=user,
+ include_compromised_check=True,
+ )
+ apply_password_evaluation(user, password_evaluation)
+
user_session = create_user_session(db=db, user=user, request=request)
access_token = create_access_token(
@@ -162,7 +170,6 @@ async def update_user_session_last_seen(request: Request, call_next):
authenticated_router.include_router(authenticated_meter_router)
authenticated_router.include_router(notifications_router)
authenticated_router.include_router(part_router)
-authenticated_router.include_router(work_orders_router)
authenticated_router.include_router(authenticated_well_measurement_router)
authenticated_router.include_router(authenticated_well_router)
authenticated_router.include_router(settings_router)
@@ -177,4 +184,5 @@ async def update_user_session_last_seen(request: Request, call_next):
app.include_router(public_maintenance_router)
app.include_router(public_well_measurement_router)
app.include_router(user_sessions_router)
+app.include_router(work_orders_router)
app.include_router(authenticated_router)
diff --git a/api/models/__init__.py b/api/models/__init__.py
index ff65bc7f..8a4cd1dd 100644
--- a/api/models/__init__.py
+++ b/api/models/__init__.py
@@ -23,6 +23,7 @@
Notifications,
ScopesRoles,
SecurityScopes,
+ ServiceAccountApiKeys,
SignOutReasonTypeLU,
UserRoles,
UserSessions,
@@ -56,6 +57,7 @@
"PropertyUnits",
"ScopesRoles",
"SecurityScopes",
+ "ServiceAccountApiKeys",
"ServiceTypeLU",
"ServicesPerformed",
"SignOutReasonTypeLU",
diff --git a/api/models/user.py b/api/models/user.py
index 9d68c927..e24f04c8 100644
--- a/api/models/user.py
+++ b/api/models/user.py
@@ -11,6 +11,9 @@ class Users(Base):
full_name: Mapped[str] = mapped_column(String)
disabled: Mapped[bool] = mapped_column(Boolean, default=False)
+ is_service_account: Mapped[bool] = mapped_column(
+ Boolean, nullable=False, default=False
+ )
username: Mapped[str] = deferred(mapped_column(String, nullable=False))
email: Mapped[str] = deferred(mapped_column(String))
hashed_password: Mapped[str] = deferred(mapped_column(String, nullable=False))
@@ -22,6 +25,20 @@ 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)
+ password_changed_at: Mapped[Optional[DateTime]] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
+ password_strength_score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
+ password_strength_label: Mapped[Optional[str]] = mapped_column(String, nullable=True)
+ password_policy_compliant: Mapped[Optional[bool]] = mapped_column(
+ Boolean, nullable=True
+ )
+ password_compromised_checked_at: Mapped[Optional[DateTime]] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
+ password_compromised_count: Mapped[Optional[int]] = mapped_column(
+ Integer, nullable=True
+ )
notifications: Mapped[List["Notifications"]] = relationship(
"Notifications",
back_populates="user",
@@ -38,6 +55,33 @@ class Users(Base):
back_populates="user",
cascade="all, delete-orphan",
)
+ service_account_api_keys: Mapped[List["ServiceAccountApiKeys"]] = relationship(
+ "ServiceAccountApiKeys",
+ back_populates="user",
+ cascade="all, delete-orphan",
+ )
+
+
+class ServiceAccountApiKeys(Base):
+ __tablename__ = "service_account_api_keys"
+
+ user_id: Mapped[int] = mapped_column(
+ Integer, ForeignKey("Users.id", ondelete="CASCADE", onupdate="CASCADE"), index=True
+ )
+ key_identifier: Mapped[str] = mapped_column(
+ String(32), nullable=False, unique=True, index=True
+ )
+ key_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
+ key_prefix: Mapped[str] = mapped_column(String(32), nullable=False)
+ created_at: Mapped[DateTime] = mapped_column(
+ DateTime, nullable=False, server_default=func.now(), index=True
+ )
+ last_used_at: Mapped[Optional[DateTime]] = mapped_column(DateTime, index=True)
+ revoked_at: Mapped[Optional[DateTime]] = mapped_column(DateTime, index=True)
+
+ user: Mapped["Users"] = relationship(
+ "Users", back_populates="service_account_api_keys"
+ )
class SignOutReasonTypeLU(Base):
diff --git a/api/routes/admin.py b/api/routes/admin.py
index 60bf2be7..aacadbd6 100644
--- a/api/routes/admin.py
+++ b/api/routes/admin.py
@@ -1,13 +1,14 @@
-from datetime import timedelta
+from datetime import datetime, timezone, timedelta
+import secrets
+import string
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.models.user import ServiceAccountApiKeys, Users, UserRoles, SecurityScopes
from api.schemas import security
from api.schemas import admin
@@ -15,7 +16,13 @@
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.auth.password_policy import apply_password_evaluation, evaluate_password
+from api.security import (
+ create_access_token,
+ create_service_account_api_key,
+ ACCESS_TOKEN_EXPIRE_HOURS,
+ get_password_hash,
+)
from api.config import settings
from pathlib import Path
@@ -24,36 +31,161 @@
import os
import subprocess
-import datetime
+import datetime as dt
-admin_router = APIRouter()
-pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+admin_router = APIRouter()
BUCKET_NAME = os.getenv("GCP_BUCKET_NAME", "")
BACKUP_PREFIX = os.getenv("GCP_BACKUP_PREFIX", "")
BACKUP_RETENTION_DAYS = int(os.getenv("BACKUP_RETENTION_DAYS", "30"))
load_dotenv(os.getenv("APPDB_ENV", ".env"))
-DATABASE_URL = os.getenv("DATABASE_URL", "")
-
-
-# define response models
+DATABASE_URL = os.getenv("DATABASE_URL", "")
+PASSWORD_GENERATION_ATTEMPTS = 8
+PASSWORD_GENERATION_LENGTH = 20
+PASSWORD_SYMBOLS = "!@#$%^&*()-_=+[]{}:,.?"
+
+
+def _generate_password_candidate() -> str:
+ random = secrets.SystemRandom()
+ required_characters = [
+ secrets.choice(string.ascii_lowercase),
+ secrets.choice(string.ascii_uppercase),
+ secrets.choice(string.digits),
+ secrets.choice(PASSWORD_SYMBOLS),
+ ]
+ alphabet = string.ascii_letters + string.digits + PASSWORD_SYMBOLS
+ remaining_characters = [
+ secrets.choice(alphabet)
+ for _ in range(PASSWORD_GENERATION_LENGTH - len(required_characters))
+ ]
+ characters = required_characters + remaining_characters
+ random.shuffle(characters)
+ return "".join(characters)
+
+
+def _validate_new_password(password: str, user: Users) -> None:
+ evaluation = evaluate_password(
+ password,
+ user=user,
+ include_compromised_check=True,
+ )
+ if not evaluation.is_policy_compliant:
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "message": "Password does not meet password requirements.",
+ "missing_requirements": evaluation.missing_requirements,
+ },
+ )
+
+ if evaluation.compromised_count is not None and evaluation.compromised_count > 0:
+ raise HTTPException(
+ status_code=400,
+ detail="Password appears in known compromised password lists.",
+ )
+
+ user.hashed_password = get_password_hash(password)
+ user.password_changed_at = datetime.now(timezone.utc)
+ apply_password_evaluation(user, evaluation)
+
+
+def _serialize_service_account(
+ service_account: Users,
+ api_key: str | None = None,
+) -> security.ServiceAccount | security.ServiceAccountWithKey:
+ payload = {
+ "id": service_account.id,
+ "username": service_account.username,
+ "email": service_account.email,
+ "full_name": service_account.full_name,
+ "disabled": service_account.disabled,
+ "user_role_id": service_account.user_role_id,
+ "user_role": service_account.user_role,
+ "display_name": service_account.display_name,
+ "redirect_page": service_account.redirect_page,
+ "avatar_img": service_account.avatar_img,
+ "password_changed_at": service_account.password_changed_at,
+ "password_strength_score": service_account.password_strength_score,
+ "password_strength_label": service_account.password_strength_label,
+ "password_policy_compliant": service_account.password_policy_compliant,
+ "password_compromised_checked_at": service_account.password_compromised_checked_at,
+ "password_compromised_count": service_account.password_compromised_count,
+ "is_service_account": service_account.is_service_account,
+ "api_keys": service_account.service_account_api_keys,
+ }
+ if api_key is not None:
+ payload["api_key"] = api_key
+ return security.ServiceAccountWithKey(**payload)
+ return security.ServiceAccount(**payload)
+
+
+def _service_account_query():
+ return select(Users).options(
+ undefer(Users.username),
+ undefer(Users.email),
+ undefer(Users.user_role_id),
+ joinedload(Users.user_role).joinedload(UserRoles.security_scopes),
+ joinedload(Users.service_account_api_keys),
+ )
+
+
+@admin_router.post(
+ "/users/{id}/generate_password",
+ response_model=security.GeneratedPasswordResponse,
+ dependencies=[Depends(ScopedUser.Admin)],
+ tags=["Admin"],
+)
+def generate_user_password(id: int, db: Session = Depends(get_db)):
+ user = db.scalars(select(Users).where(Users.id == id)).first()
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ if user.is_service_account:
+ raise HTTPException(
+ status_code=400,
+ detail="Service accounts do not use passwords.",
+ )
+
+ for _ in range(PASSWORD_GENERATION_ATTEMPTS):
+ password = _generate_password_candidate()
+ evaluation = evaluate_password(
+ password,
+ user=user,
+ include_compromised_check=True,
+ )
+ if evaluation.is_policy_compliant and evaluation.compromised_count == 0:
+ return security.GeneratedPasswordResponse(password=password)
+
+ raise HTTPException(
+ status_code=503,
+ detail="Unable to generate a verified uncompromised password. Please try again.",
+ )
+
+
+# define response models
@admin_router.post(
"/users/update_password",
response_model=security.User,
dependencies=[Depends(ScopedUser.Admin)],
tags=["Admin"],
)
-def update_user_password(
+def update_user_password(
updatedUserPassword: security.UpdatedUserPassword,
- db: Session = Depends(get_db),
-):
- user = db.scalars(
- select(Users).where(Users.id == updatedUserPassword.user_id)
- ).first()
-
- user.hashed_password = pwd_context.hash(updatedUserPassword.new_password)
- db.commit()
- db.refresh(user)
+ db: Session = Depends(get_db),
+):
+ user = db.scalars(
+ select(Users).where(Users.id == updatedUserPassword.user_id)
+ ).first()
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ if user.is_service_account:
+ raise HTTPException(
+ status_code=400,
+ detail="Service accounts do not use passwords.",
+ )
+
+ _validate_new_password(updatedUserPassword.new_password, user)
+ db.commit()
+ db.refresh(user)
return user
@@ -64,10 +196,17 @@ def update_user_password(
dependencies=[Depends(ScopedUser.Admin)],
tags=["Admin"],
)
-def update_user(
+def update_user(
updated_user: security.UpdatedUser, db: Session = Depends(get_db)
-):
- _patch(db, Users, updated_user.id, updated_user)
+):
+ user = db.scalars(select(Users).where(Users.id == updated_user.id)).first()
+ if user and user.is_service_account:
+ raise HTTPException(
+ status_code=400,
+ detail="Use service account endpoints to update service accounts.",
+ )
+
+ _patch(db, Users, updated_user.id, updated_user)
qualified_user = db.scalars(
select(Users)
@@ -90,17 +229,18 @@ def update_user(
tags=["Admin"],
)
def create_user(user: security.NewUser, db: Session = Depends(get_db)):
- new_user = Users(
+ new_user = Users(
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),
- )
-
- db.add(new_user)
+ display_name=user.display_name,
+ user_role_id=user.user_role_id,
+ disabled=user.disabled,
+ hashed_password="",
+ )
+ _validate_new_password(user.password, new_user)
+
+ db.add(new_user)
db.commit()
qualified_user = db.scalars(
@@ -123,7 +263,7 @@ def create_user(user: security.NewUser, db: Session = Depends(get_db)):
dependencies=[Depends(ScopedUser.Admin)],
tags=["Admin"],
)
-def get_user_admin(id: int, db: Session = Depends(get_db)):
+def get_user_admin(id: int, db: Session = Depends(get_db)):
"""
Admin-specific single user endpoint (includes username/email/role)
"""
@@ -135,8 +275,9 @@ def get_user_admin(id: int, db: Session = Depends(get_db)):
undefer(Users.email),
joinedload(Users.user_role),
)
- .where(Users.id == id)
- ).first()
+ .where(Users.id == id)
+ .where(Users.is_service_account.is_(False))
+ ).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
@@ -161,13 +302,181 @@ def get_users_admin(db: Session = Depends(get_db)):
undefer(Users.user_role_id),
undefer(Users.email),
joinedload(Users.user_role),
- )
- )
+ ).where(Users.is_service_account.is_(False))
+ )
.unique()
.all()
)
+@admin_router.get(
+ "/service-accounts",
+ response_model=List[security.ServiceAccount],
+ dependencies=[Depends(ScopedUser.Admin)],
+ tags=["Admin"],
+)
+def get_service_accounts(db: Session = Depends(get_db)):
+ service_accounts = (
+ db.scalars(
+ _service_account_query()
+ .where(Users.is_service_account.is_(True))
+ .order_by(Users.username)
+ )
+ .unique()
+ .all()
+ )
+ return [
+ _serialize_service_account(service_account)
+ for service_account in service_accounts
+ ]
+
+
+@admin_router.post(
+ "/service-accounts",
+ response_model=security.ServiceAccountWithKey,
+ dependencies=[Depends(ScopedUser.Admin)],
+ tags=["Admin"],
+)
+def create_service_account(
+ service_account: security.NewServiceAccount,
+ db: Session = Depends(get_db),
+):
+ existing_user = db.scalars(
+ select(Users).where(Users.username == service_account.username)
+ ).first()
+ if existing_user:
+ raise HTTPException(status_code=409, detail="Username already exists")
+
+ role = db.scalars(
+ select(UserRoles).where(UserRoles.id == service_account.user_role_id)
+ ).first()
+ if not role:
+ raise HTTPException(status_code=404, detail="Role not found")
+
+ new_service_account = Users(
+ username=service_account.username,
+ email=None,
+ full_name=service_account.full_name,
+ display_name=service_account.display_name,
+ user_role_id=service_account.user_role_id,
+ disabled=service_account.disabled,
+ is_service_account=True,
+ hashed_password=get_password_hash(secrets.token_urlsafe(32)),
+ )
+ db.add(new_service_account)
+ db.flush()
+ _, api_key = create_service_account_api_key(db, new_service_account)
+ db.commit()
+
+ qualified_service_account = (
+ db.scalars(_service_account_query().where(Users.id == new_service_account.id))
+ .unique()
+ .first()
+ )
+ return _serialize_service_account(qualified_service_account, api_key=api_key)
+
+
+@admin_router.patch(
+ "/service-accounts/{id}",
+ response_model=security.ServiceAccount,
+ dependencies=[Depends(ScopedUser.Admin)],
+ tags=["Admin"],
+)
+def update_service_account(
+ id: int,
+ updated_service_account: security.UpdatedServiceAccount,
+ db: Session = Depends(get_db),
+):
+ service_account = db.scalars(
+ select(Users).where(
+ Users.id == id,
+ Users.is_service_account.is_(True),
+ )
+ ).first()
+ if not service_account:
+ raise HTTPException(status_code=404, detail="Service account not found")
+
+ if updated_service_account.user_role_id is not None:
+ role = db.scalars(
+ select(UserRoles).where(UserRoles.id == updated_service_account.user_role_id)
+ ).first()
+ if not role:
+ raise HTTPException(status_code=404, detail="Role not found")
+ service_account.user_role_id = updated_service_account.user_role_id
+ if updated_service_account.full_name is not None:
+ service_account.full_name = updated_service_account.full_name
+ if updated_service_account.display_name is not None:
+ service_account.display_name = updated_service_account.display_name
+ if updated_service_account.disabled is not None:
+ service_account.disabled = updated_service_account.disabled
+
+ db.commit()
+ qualified_service_account = (
+ db.scalars(_service_account_query().where(Users.id == service_account.id))
+ .unique()
+ .first()
+ )
+ return _serialize_service_account(qualified_service_account)
+
+
+@admin_router.post(
+ "/service-accounts/{id}/keys",
+ response_model=security.ServiceAccountWithKey,
+ dependencies=[Depends(ScopedUser.Admin)],
+ tags=["Admin"],
+)
+def create_service_account_key(id: int, db: Session = Depends(get_db)):
+ service_account = db.scalars(
+ select(Users).where(
+ Users.id == id,
+ Users.is_service_account.is_(True),
+ )
+ ).first()
+ if not service_account:
+ raise HTTPException(status_code=404, detail="Service account not found")
+
+ _, api_key = create_service_account_api_key(db, service_account)
+ db.commit()
+ qualified_service_account = (
+ db.scalars(_service_account_query().where(Users.id == service_account.id))
+ .unique()
+ .first()
+ )
+ return _serialize_service_account(qualified_service_account, api_key=api_key)
+
+
+@admin_router.delete(
+ "/service-accounts/{id}/keys/{key_identifier}",
+ dependencies=[Depends(ScopedUser.Admin)],
+ tags=["Admin"],
+)
+def revoke_service_account_key(
+ id: int,
+ key_identifier: str,
+ db: Session = Depends(get_db),
+):
+ api_key = (
+ db.scalars(
+ select(ServiceAccountApiKeys)
+ .join(ServiceAccountApiKeys.user)
+ .where(
+ ServiceAccountApiKeys.key_identifier == key_identifier,
+ ServiceAccountApiKeys.user_id == id,
+ Users.is_service_account.is_(True),
+ )
+ )
+ .unique()
+ .first()
+ )
+ if not api_key:
+ raise HTTPException(status_code=404, detail="Service account key not found")
+
+ api_key.revoked_at = datetime.now(timezone.utc)
+ db.add(api_key)
+ db.commit()
+ return {"status": "revoked"}
+
+
@admin_router.post(
"/users/{id}/impersonate",
response_model=security.Token,
@@ -195,6 +504,7 @@ def impersonate_user(
joinedload(Users.user_role).joinedload(UserRoles.security_scopes),
)
.where(Users.id == id)
+ .where(Users.is_service_account.is_(False))
).first()
if not target_user:
@@ -202,6 +512,8 @@ def impersonate_user(
if target_user.disabled:
raise HTTPException(status_code=400, detail="Cannot impersonate a disabled user")
+ if target_user.is_service_account:
+ raise HTTPException(status_code=400, detail="Cannot impersonate a service account")
user_session = create_user_session(db=db, user=target_user, request=request)
@@ -423,7 +735,7 @@ def backup_and_send():
raise ValueError("DATABASE_URL environment variable is not set")
# Use UTC-aware timestamp
- timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d-%H%M%S")
+ timestamp = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d-%H%M%S")
filename = f"backup-{timestamp}.dump"
local_path = Path(f"/tmp/{filename}")
@@ -441,7 +753,7 @@ def backup_and_send():
local_path.unlink(missing_ok=True)
# Delete old backups (> BACKUP_RETENTION_DAYS) using UTC-aware cutoff
- cutoff_date = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
+ cutoff_date = dt.datetime.now(dt.timezone.utc) - dt.timedelta(
days=BACKUP_RETENTION_DAYS
)
blobs = client.list_blobs(BUCKET_NAME, prefix=BACKUP_PREFIX)
diff --git a/api/routes/meters.py b/api/routes/meters.py
index 9cd09a1b..918c31d5 100644
--- a/api/routes/meters.py
+++ b/api/routes/meters.py
@@ -1,5 +1,7 @@
+from datetime import date
from typing import List
from fastapi import Depends, APIRouter, HTTPException, Query
+from fastapi.responses import StreamingResponse
from sqlalchemy import or_, select, desc, and_, text
from sqlalchemy.orm import Session, joinedload
from sqlalchemy.exc import IntegrityError
@@ -105,6 +107,104 @@ def sort_by_field_to_schema_field(name: MeterSortByField):
return paginate(db, query_statement)
+@authenticated_meter_router.get(
+ "/meters/sold-report",
+ dependencies=[Depends(ScopedUser.Read)],
+ tags=["Meters"],
+)
+def get_sold_meters_report(
+ from_date: date = Query(..., description="Start date YYYY-MM-DD"),
+ to_date: date = Query(..., description="End date YYYY-MM-DD"),
+ min_size: int | None = Query(None, ge=0),
+ max_size: int | None = Query(None, ge=0),
+ db: Session = Depends(get_db),
+):
+ return meter_service.get_sold_meters_report(
+ db,
+ from_date,
+ to_date,
+ min_size,
+ max_size,
+ )
+
+
+@authenticated_meter_router.get(
+ "/meters/sold-report/pdf",
+ dependencies=[Depends(ScopedUser.Read)],
+ tags=["Meters"],
+)
+def download_sold_meters_pdf(
+ from_date: date = Query(..., description="Start date YYYY-MM-DD"),
+ to_date: date = Query(..., description="End date YYYY-MM-DD"),
+ min_size: int | None = Query(None, ge=0),
+ max_size: int | None = Query(None, ge=0),
+ db: Session = Depends(get_db),
+):
+ pdf_io = meter_service.build_sold_meters_pdf(
+ db,
+ from_date,
+ to_date,
+ min_size,
+ max_size,
+ )
+
+ return StreamingResponse(
+ pdf_io,
+ media_type="application/pdf",
+ headers={"Content-Disposition": "attachment; filename=sold_meters_report.pdf"},
+ )
+
+
+@authenticated_meter_router.get(
+ "/meters/installed-report",
+ dependencies=[Depends(ScopedUser.Read)],
+ tags=["Meters"],
+)
+def get_installed_meters_report(
+ from_date: date = Query(..., description="Start date YYYY-MM-DD"),
+ to_date: date = Query(..., description="End date YYYY-MM-DD"),
+ min_size: int | None = Query(None, ge=0),
+ max_size: int | None = Query(None, ge=0),
+ db: Session = Depends(get_db),
+):
+ return meter_service.get_installed_meters_report(
+ db,
+ from_date,
+ to_date,
+ min_size,
+ max_size,
+ )
+
+
+@authenticated_meter_router.get(
+ "/meters/installed-report/pdf",
+ dependencies=[Depends(ScopedUser.Read)],
+ tags=["Meters"],
+)
+def download_installed_meters_pdf(
+ from_date: date = Query(..., description="Start date YYYY-MM-DD"),
+ to_date: date = Query(..., description="End date YYYY-MM-DD"),
+ min_size: int | None = Query(None, ge=0),
+ max_size: int | None = Query(None, ge=0),
+ db: Session = Depends(get_db),
+):
+ pdf_io = meter_service.build_installed_meters_pdf(
+ db,
+ from_date,
+ to_date,
+ min_size,
+ max_size,
+ )
+
+ return StreamingResponse(
+ pdf_io,
+ media_type="application/pdf",
+ headers={
+ "Content-Disposition": "attachment; filename=installed_meters_report.pdf"
+ },
+ )
+
+
@authenticated_meter_router.post(
"/meters",
response_model=meter.Meter,
diff --git a/api/routes/settings.py b/api/routes/settings.py
index 4d2ce294..1cd3adc5 100644
--- a/api/routes/settings.py
+++ b/api/routes/settings.py
@@ -1,4 +1,5 @@
from base64 import b64encode
+from datetime import datetime, timezone
from io import BytesIO
from fastapi import Depends, APIRouter, HTTPException, File, UploadFile
@@ -9,6 +10,10 @@
from api.session import get_db
from api.security import get_current_user, get_password_hash, verify_password
from api.models.user import Users
+from api.auth.password_policy import (
+ apply_password_evaluation,
+ evaluate_password,
+)
settings_router = APIRouter()
@@ -22,6 +27,22 @@
}
+def _serialize_datetime(value):
+ return value.isoformat() if value else None
+
+
+def _password_evaluation_response(evaluation):
+ return settings.PasswordEvaluationResponse(
+ score=evaluation.score,
+ label=evaluation.label,
+ is_policy_compliant=evaluation.is_policy_compliant,
+ missing_requirements=evaluation.missing_requirements,
+ compromised_count=evaluation.compromised_count,
+ compromised_checked_at=_serialize_datetime(evaluation.compromised_checked_at),
+ compromised_check_error=evaluation.compromised_check_error,
+ )
+
+
@settings_router.get(
"/settings/redirect_page",
tags=["settings"],
@@ -75,6 +96,53 @@ def post_redirect_page(
return {"message": "Display name updated", "display_name": db_user.display_name}
+@settings_router.get(
+ "/settings/password_status",
+ response_model=settings.PasswordStatusResponse,
+ tags=["settings"],
+)
+def get_password_status(
+ 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")
+
+ return settings.PasswordStatusResponse(
+ password_changed_at=_serialize_datetime(db_user.password_changed_at),
+ password_strength_score=db_user.password_strength_score,
+ password_strength_label=db_user.password_strength_label,
+ password_policy_compliant=db_user.password_policy_compliant,
+ password_compromised_checked_at=_serialize_datetime(
+ db_user.password_compromised_checked_at
+ ),
+ password_compromised_count=db_user.password_compromised_count,
+ )
+
+
+@settings_router.post(
+ "/settings/password/evaluate",
+ response_model=settings.PasswordEvaluationResponse,
+ tags=["settings"],
+)
+def post_password_evaluate(
+ request: settings.PasswordEvaluateRequest,
+ 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")
+
+ evaluation = evaluate_password(
+ request.password,
+ user=db_user,
+ include_compromised_check=True,
+ )
+ return _password_evaluation_response(evaluation)
+
+
@settings_router.post(
"/settings/password_reset",
tags=["settings"],
@@ -97,13 +165,29 @@ def post_password_reset(
detail="New password must be different from current password",
)
- if len(update.new_password) < 8:
+ evaluation = evaluate_password(
+ update.new_password,
+ user=db_user,
+ include_compromised_check=True,
+ )
+ if not evaluation.is_policy_compliant:
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "message": "New password does not meet password requirements.",
+ "missing_requirements": evaluation.missing_requirements,
+ },
+ )
+
+ if evaluation.compromised_count is not None and evaluation.compromised_count > 0:
raise HTTPException(
status_code=400,
- detail="New password must be at least 8 characters long",
+ detail="New password appears in known compromised password lists.",
)
db_user.hashed_password = get_password_hash(update.new_password)
+ db_user.password_changed_at = datetime.now(timezone.utc)
+ apply_password_evaluation(db_user, evaluation)
try:
db.commit()
diff --git a/api/routes/work_orders.py b/api/routes/work_orders.py
index 9ca7ff44..bddbbbaf 100644
--- a/api/routes/work_orders.py
+++ b/api/routes/work_orders.py
@@ -18,7 +18,7 @@
@work_orders_router.get(
"/work_orders",
- dependencies=[Depends(ScopedUser.Read)],
+ dependencies=[Depends(ScopedUser.WorkOrderRead)],
tags=["Work Orders"],
)
def get_work_orders(
@@ -43,18 +43,23 @@ def get_work_orders(
@work_orders_router.post(
"/work_orders",
- dependencies=[Depends(ScopedUser.Admin)],
+ dependencies=[Depends(ScopedUser.WorkOrderCreate)],
response_model=meter.WorkOrder,
tags=["Work Orders"],
)
def create_work_order(
- new_work_order: meter.CreateWorkOrder, db: Session = Depends(get_db)
+ new_work_order: meter.CreateWorkOrder,
+ user: Users = Depends(get_current_user),
+ db: Session = Depends(get_db),
):
- return work_order_service.create_work_order(db=db, new_work_order=new_work_order)
+ return work_order_service.create_work_order(
+ db=db, user=user, new_work_order=new_work_order
+ )
@work_orders_router.patch(
"/work_orders",
+ dependencies=[Depends(ScopedUser.WorkOrderUpdate)],
response_model=meter.WorkOrder,
tags=["Work Orders"],
)
@@ -73,5 +78,11 @@ def patch_work_order(
dependencies=[Depends(ScopedUser.Admin)],
tags=["Work Orders"],
)
-def delete_work_order(work_order_id: int, db: Session = Depends(get_db)):
- return work_order_service.delete_work_order(db=db, work_order_id=work_order_id)
+def delete_work_order(
+ work_order_id: int,
+ user: Users = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ return work_order_service.delete_work_order(
+ db=db, user=user, work_order_id=work_order_id
+ )
diff --git a/api/schemas/security.py b/api/schemas/security.py
index 8252a9cf..2ea38ffa 100644
--- a/api/schemas/security.py
+++ b/api/schemas/security.py
@@ -1,5 +1,7 @@
-from api.schemas.base import ORMBase
-from pydantic import BaseModel
+from datetime import datetime
+
+from api.schemas.base import ORMBase
+from pydantic import BaseModel, Field
class SecurityScope(ORMBase):
@@ -12,43 +14,85 @@ class UserRole(ORMBase):
security_scopes: list[SecurityScope] | None = None
-class UpdatedUserPassword(ORMBase):
- user_id: int
- new_password: str
-
-
-class UpdatedUser(ORMBase):
- id: int
- username: str
+class UpdatedUserPassword(ORMBase):
+ user_id: int
+ new_password: str
+
+
+class GeneratedPasswordResponse(BaseModel):
+ password: str
+
+
+class UpdatedUser(ORMBase):
+ id: int
+ username: str
email: str
full_name: str
disabled: bool
- user_role_id: int
-
-
-class NewUser(ORMBase):
+ user_role_id: int
+
+
+class UpdatedServiceAccount(ORMBase):
+ full_name: str | None = None
+ display_name: str | None = None
+ disabled: bool | None = None
+ user_role_id: int | None = None
+
+
+class NewUser(ORMBase):
username: str
email: str
full_name: str
display_name: str
disabled: bool
- user_role_id: int
- password: str
-
-
+ user_role_id: int
+ password: str
+
+
+class NewServiceAccount(ORMBase):
+ username: str
+ full_name: str
+ display_name: str | None = None
+ user_role_id: int
+ disabled: bool = False
+
+
+class ServiceAccountApiKey(ORMBase):
+ key_identifier: str
+ key_prefix: str
+ created_at: datetime
+ last_used_at: datetime | None = None
+ revoked_at: datetime | None = None
+
+
class User(ORMBase):
- username: str | None = None
- email: str | None = None
- full_name: str | None = None
- disabled: bool
+ username: str | None = None
+ email: str | None = None
+ full_name: str | None = None
+ disabled: bool
user_role_id: int
user_role: UserRole | None = None
- display_name: str | None = None
+ display_name: str | None = None
redirect_page: str | None = None
avatar_img: str | None = None
+ password_changed_at: datetime | None = None
+ password_strength_score: int | None = None
+ password_strength_label: str | None = None
+ password_policy_compliant: bool | None = None
+ password_compromised_checked_at: datetime | None = None
+ password_compromised_count: int | None = None
+
+
+class ServiceAccount(User):
+ is_service_account: bool
+ api_keys: list[ServiceAccountApiKey] = Field(default_factory=list)
+
+
+class ServiceAccountWithKey(ServiceAccount):
+ api_key: str
class ImpersonationContext(BaseModel):
diff --git a/api/schemas/settings.py b/api/schemas/settings.py
index 1968f9b2..ff00df45 100644
--- a/api/schemas/settings.py
+++ b/api/schemas/settings.py
@@ -1,4 +1,5 @@
from api.schemas.base import ORMBase
+from pydantic import BaseModel
class RedirectPageUpdate(ORMBase):
@@ -12,3 +13,26 @@ class DisplayNameUpdate(ORMBase):
class PasswordResetRequest(ORMBase):
current_password: str
new_password: str
+
+
+class PasswordEvaluateRequest(BaseModel):
+ password: str
+
+
+class PasswordEvaluationResponse(BaseModel):
+ score: int
+ label: str
+ is_policy_compliant: bool
+ missing_requirements: list[str]
+ compromised_count: int | None = None
+ compromised_checked_at: str | None = None
+ compromised_check_error: str | None = None
+
+
+class PasswordStatusResponse(BaseModel):
+ password_changed_at: str | None = None
+ password_strength_score: int | None = None
+ password_strength_label: str | None = None
+ password_policy_compliant: bool | None = None
+ password_compromised_checked_at: str | None = None
+ password_compromised_count: int | None = None
diff --git a/api/security.py b/api/security.py
index 819e1651..be8c056f 100644
--- a/api/security.py
+++ b/api/security.py
@@ -1,4 +1,7 @@
from datetime import timedelta, datetime
+import hashlib
+import hmac
+import secrets
from typing import Union, Annotated
from fastapi import HTTPException, Depends, APIRouter, Security
@@ -10,7 +13,12 @@
from sqlalchemy.orm import joinedload, undefer, Session
from sqlalchemy.sql import select
-from api.models.user import Users, UserRoles, SecurityScopes, UserSessions
+from api.models.user import (
+ ServiceAccountApiKeys,
+ Users,
+ UserRoles,
+ UserSessions,
+)
from api.schemas import security as security_schema
from api.config import settings
from api.session import get_db
@@ -21,6 +29,7 @@
SECRET_KEY = settings.JWT_SECRET_KEY
ALGORITHM = settings.JWT_ALGORITHM
ACCESS_TOKEN_EXPIRE_HOURS = settings.ACCESS_TOKEN_EXPIRE_HOURS
+SERVICE_ACCOUNT_KEY_PREFIX = "wmdb_sa"
if not SECRET_KEY:
raise RuntimeError("JWT_SECRET_KEY environment variable must be set.")
@@ -55,6 +64,8 @@ def authenticate_user(login_identifier: str, password: str, db: Session):
user = get_user_by_login(login_identifier, db)
if not user:
return False
+ if user.is_service_account:
+ return False
if not verify_password(password, user.hashed_password):
return False
return user
@@ -115,10 +126,74 @@ def get_user(username: str, db: Session) -> Users:
return dbuser
+def _hash_service_account_key(api_key: str) -> str:
+ return hashlib.sha256(api_key.encode("utf-8")).hexdigest()
+
+
+def create_service_account_api_key(db: Session, user: Users) -> tuple[ServiceAccountApiKeys, str]:
+ key_identifier = secrets.token_urlsafe(12).replace("-", "").replace("_", "")[:16]
+ secret = secrets.token_urlsafe(32)
+ api_key = f"{SERVICE_ACCOUNT_KEY_PREFIX}_{key_identifier}_{secret}"
+ key = ServiceAccountApiKeys(
+ user_id=user.id,
+ key_identifier=key_identifier,
+ key_hash=_hash_service_account_key(api_key),
+ key_prefix=api_key[:24],
+ )
+ db.add(key)
+ return key, api_key
+
+
+def get_user_by_service_account_key(api_key: str, db: Session) -> Users | None:
+ parts = api_key.split("_", 3)
+ if len(parts) != 4 or "_".join(parts[:2]) != SERVICE_ACCOUNT_KEY_PREFIX:
+ return None
+
+ key_identifier = parts[2]
+ key = (
+ db.scalars(
+ select(ServiceAccountApiKeys)
+ .options(
+ joinedload(ServiceAccountApiKeys.user).options(
+ undefer(Users.username),
+ undefer(Users.user_role_id),
+ undefer(Users.email),
+ joinedload(Users.user_role).joinedload(UserRoles.security_scopes),
+ ),
+ )
+ .where(
+ ServiceAccountApiKeys.key_identifier == key_identifier,
+ ServiceAccountApiKeys.revoked_at.is_(None),
+ )
+ )
+ .unique()
+ .first()
+ )
+
+ if key is None:
+ return None
+
+ if not hmac.compare_digest(key.key_hash, _hash_service_account_key(api_key)):
+ return None
+
+ user = key.user
+ if user is None or not user.is_service_account or user.disabled:
+ return None
+
+ key.last_used_at = datetime.utcnow()
+ db.add(key)
+ db.commit()
+ return user
+
+
def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
db: Annotated[Session, Depends(get_db)],
) -> Users:
+ service_account_user = get_user_by_service_account_key(token, db)
+ if service_account_user:
+ return service_account_user
+
try:
payload = decode_access_token(token)
diff --git a/api/services/meters.py b/api/services/meters.py
index 61f22ce4..a8023529 100644
--- a/api/services/meters.py
+++ b/api/services/meters.py
@@ -1,14 +1,38 @@
+from base64 import b64encode
from enum import Enum
+from datetime import date, datetime
+from io import BytesIO
+from pathlib import Path
+import matplotlib
+matplotlib.use("Agg")
+
+from jinja2 import Environment, FileSystemLoader, select_autoescape
+from matplotlib.pyplot import close, figure
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
+from weasyprint import HTML
-from api.models.meter import MeterActivities, MeterObservations
+from api.models.meter import (
+ ActivityTypeLU,
+ MeterActivities,
+ MeterObservations,
+ Meters,
+ MeterTypeLU,
+)
+from api.models.location import Locations
from api.models.part import Parts, PartsUsed
from api.models.well import Wells
from api.services.storage import create_signed_url
+TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
+templates = Environment(
+ loader=FileSystemLoader(TEMPLATES_DIR),
+ autoescape=select_autoescape(["html", "xml"]),
+)
+
+
class HistoryType(Enum):
Activity = "Activity"
Observation = "Observation"
@@ -96,3 +120,279 @@ def get_meter_history(db: Session, meter_id: int):
formatted_history_items.sort(key=lambda item: item["date"], reverse=True)
return formatted_history_items
+
+
+def _meter_type_label(meter_type: MeterTypeLU) -> str:
+ return " ".join(
+ filter(
+ None,
+ [
+ meter_type.brand,
+ meter_type.series,
+ meter_type.model,
+ f'{meter_type.size:g}"',
+ ],
+ )
+ )
+
+
+def _make_meter_type_bar_chart(type_totals: list[dict], series_label: str) -> str:
+ if not type_totals:
+ return ""
+
+ labels = [row["meter_type"] for row in type_totals]
+ quantities = [row["quantity"] for row in type_totals]
+ width = max(8, min(14, len(labels) * 1.2))
+
+ fig = figure(figsize=(width, 5))
+ ax = fig.add_subplot(111)
+ bars = ax.bar(labels, quantities, label=series_label, color="#1976d2")
+
+ ax.set_title("Meter Type Totals")
+ ax.set_xlabel("Meter Type")
+ ax.set_ylabel("Quantity")
+ ax.set_ylim(0, max(quantities) + 1)
+ ax.legend()
+ ax.bar_label(bars, padding=3)
+ ax.tick_params(axis="x", labelrotation=35)
+
+ fig.tight_layout()
+ buf = BytesIO()
+ fig.savefig(buf, format="png", bbox_inches="tight")
+ close(fig)
+ return b64encode(buf.getvalue()).decode("utf-8")
+
+
+def get_sold_meters_report(
+ db: Session,
+ from_date: date,
+ to_date: date,
+ min_size: int | None = None,
+ max_size: int | None = None,
+):
+ start_dt = datetime.combine(from_date, datetime.min.time())
+ end_dt = datetime.combine(to_date, datetime.max.time())
+
+ stmt = (
+ select(MeterActivities, Meters, MeterTypeLU)
+ .join(ActivityTypeLU, ActivityTypeLU.id == MeterActivities.activity_type_id)
+ .join(Meters, Meters.id == MeterActivities.meter_id)
+ .join(MeterTypeLU, MeterTypeLU.id == Meters.meter_type_id)
+ .where(
+ ActivityTypeLU.name == "Sell",
+ MeterActivities.timestamp_start >= start_dt,
+ MeterActivities.timestamp_start <= end_dt,
+ )
+ .order_by(MeterActivities.timestamp_start.asc(), Meters.serial_number.asc())
+ )
+
+ if min_size is not None:
+ stmt = stmt.where(MeterTypeLU.size >= min_size)
+ if max_size is not None:
+ stmt = stmt.where(MeterTypeLU.size <= max_size)
+
+ rows = []
+ type_totals_by_id = {}
+ total_value = 0.0
+
+ for activity, meter, meter_type in db.execute(stmt).all():
+ price = float(meter.price or 0)
+ total_value += price
+ meter_type_label = _meter_type_label(meter_type)
+
+ rows.append(
+ {
+ "id": activity.id,
+ "activity_id": activity.id,
+ "sold_date": activity.timestamp_start,
+ "serial_number": meter.serial_number,
+ "meter_owner": meter.meter_owner,
+ "contact_name": meter.contact_name,
+ "price": price,
+ "meter_type_id": meter_type.id,
+ "meter_type": meter_type_label,
+ "brand": meter_type.brand,
+ "series": meter_type.series,
+ "model": meter_type.model,
+ "size": meter_type.size,
+ "description": meter_type.description,
+ }
+ )
+
+ if meter_type.id not in type_totals_by_id:
+ type_totals_by_id[meter_type.id] = {
+ "id": meter_type.id,
+ "meter_type": meter_type_label,
+ "brand": meter_type.brand,
+ "series": meter_type.series,
+ "model": meter_type.model,
+ "size": meter_type.size,
+ "description": meter_type.description,
+ "quantity": 0,
+ "total_value": 0.0,
+ }
+ type_totals_by_id[meter_type.id]["quantity"] += 1
+ type_totals_by_id[meter_type.id]["total_value"] += price
+
+ type_totals = sorted(
+ type_totals_by_id.values(),
+ key=lambda row: (row["size"], row["meter_type"]),
+ )
+
+ return {
+ "rows": rows,
+ "summary": {
+ "quantity": len(rows),
+ "total_value": total_value,
+ },
+ "type_totals": type_totals,
+ }
+
+
+def get_installed_meters_report(
+ db: Session,
+ from_date: date,
+ to_date: date,
+ min_size: int | None = None,
+ max_size: int | None = None,
+):
+ start_dt = datetime.combine(from_date, datetime.min.time())
+ end_dt = datetime.combine(to_date, datetime.max.time())
+
+ stmt = (
+ select(MeterActivities, Meters, MeterTypeLU, Locations, Wells)
+ .join(ActivityTypeLU, ActivityTypeLU.id == MeterActivities.activity_type_id)
+ .join(Meters, Meters.id == MeterActivities.meter_id)
+ .join(MeterTypeLU, MeterTypeLU.id == Meters.meter_type_id)
+ .join(Locations, Locations.id == MeterActivities.location_id, isouter=True)
+ .join(Wells, Wells.location_id == MeterActivities.location_id, isouter=True)
+ .where(
+ ActivityTypeLU.name == "Install",
+ MeterActivities.timestamp_start >= start_dt,
+ MeterActivities.timestamp_start <= end_dt,
+ )
+ .order_by(MeterActivities.timestamp_start.asc(), Meters.serial_number.asc())
+ )
+
+ if min_size is not None:
+ stmt = stmt.where(MeterTypeLU.size >= min_size)
+ if max_size is not None:
+ stmt = stmt.where(MeterTypeLU.size <= max_size)
+
+ rows = []
+ type_totals_by_id = {}
+ total_value = 0.0
+
+ for activity, meter, meter_type, location, well in db.execute(stmt).all():
+ price = float(meter.price or 0)
+ total_value += price
+ meter_type_label = _meter_type_label(meter_type)
+
+ rows.append(
+ {
+ "id": activity.id,
+ "activity_id": activity.id,
+ "installed_date": activity.timestamp_start,
+ "serial_number": meter.serial_number,
+ "meter_owner": meter.meter_owner,
+ "contact_name": meter.contact_name,
+ "water_users": activity.water_users,
+ "well_ra_number": well.ra_number if well else None,
+ "trss": location.trss if location else None,
+ "price": price,
+ "meter_type_id": meter_type.id,
+ "meter_type": meter_type_label,
+ "brand": meter_type.brand,
+ "series": meter_type.series,
+ "model": meter_type.model,
+ "size": meter_type.size,
+ "description": meter_type.description,
+ }
+ )
+
+ if meter_type.id not in type_totals_by_id:
+ type_totals_by_id[meter_type.id] = {
+ "id": meter_type.id,
+ "meter_type": meter_type_label,
+ "brand": meter_type.brand,
+ "series": meter_type.series,
+ "model": meter_type.model,
+ "size": meter_type.size,
+ "description": meter_type.description,
+ "quantity": 0,
+ "total_value": 0.0,
+ }
+ type_totals_by_id[meter_type.id]["quantity"] += 1
+ type_totals_by_id[meter_type.id]["total_value"] += price
+
+ type_totals = sorted(
+ type_totals_by_id.values(),
+ key=lambda row: (row["size"], row["meter_type"]),
+ )
+
+ return {
+ "rows": rows,
+ "summary": {
+ "quantity": len(rows),
+ "total_value": total_value,
+ },
+ "type_totals": type_totals,
+ }
+
+
+def build_sold_meters_pdf(
+ db: Session,
+ from_date: date,
+ to_date: date,
+ min_size: int | None = None,
+ max_size: int | None = None,
+):
+ report = get_sold_meters_report(db, from_date, to_date, min_size, max_size)
+ meter_type_chart = _make_meter_type_bar_chart(
+ report["type_totals"],
+ "Meters Sold",
+ )
+
+ html_content = templates.get_template("sold_meters_report.html").render(
+ rows=report["rows"],
+ summary=report["summary"],
+ type_totals=report["type_totals"],
+ meter_type_chart=meter_type_chart,
+ from_date=from_date,
+ to_date=to_date,
+ min_size=min_size,
+ max_size=max_size,
+ )
+ pdf_io = BytesIO()
+ HTML(string=html_content).write_pdf(pdf_io)
+ pdf_io.seek(0)
+ return pdf_io
+
+
+def build_installed_meters_pdf(
+ db: Session,
+ from_date: date,
+ to_date: date,
+ min_size: int | None = None,
+ max_size: int | None = None,
+):
+ report = get_installed_meters_report(db, from_date, to_date, min_size, max_size)
+ meter_type_chart = _make_meter_type_bar_chart(
+ report["type_totals"],
+ "Meters Installed",
+ )
+
+ html_content = templates.get_template("installed_meters_report.html").render(
+ rows=report["rows"],
+ summary=report["summary"],
+ type_totals=report["type_totals"],
+ meter_type_chart=meter_type_chart,
+ from_date=from_date,
+ to_date=to_date,
+ min_size=min_size,
+ max_size=max_size,
+ )
+ pdf_io = BytesIO()
+ HTML(string=html_content).write_pdf(pdf_io)
+ pdf_io.seek(0)
+ return pdf_io
diff --git a/api/services/parts.py b/api/services/parts.py
index 039805c5..be6442fb 100644
--- a/api/services/parts.py
+++ b/api/services/parts.py
@@ -10,7 +10,7 @@
from weasyprint import HTML
from api.models.meter import ActivityTypeLU, MeterActivities, meterRegisters
-from api.models.part import Parts, PartsAdded, PartsUsed
+from api.models.part import PartTypeLU, Parts, PartsAdded, PartsUsed
from api.schemas import parts
@@ -196,11 +196,13 @@ def get_parts_used_summary(
Parts.part_number,
Parts.description,
Parts.price,
+ PartTypeLU.name.label("part_type"),
func.coalesce(usage_subq.c.quantity, 0).label("quantity"),
)
.outerjoin(usage_subq, Parts.id == usage_subq.c.used_part_id)
+ .join(PartTypeLU, PartTypeLU.id == Parts.part_type_id)
.filter(Parts.id.in_(parts))
- .order_by(Parts.part_number)
+ .order_by(PartTypeLU.name, Parts.part_number)
)
results = []
for row in query.all():
@@ -211,6 +213,7 @@ def get_parts_used_summary(
"id": row.id,
"part_number": row.part_number,
"description": row.description,
+ "part_type": row.part_type,
"price": price,
"quantity": quantity,
"total": price * quantity,
@@ -226,8 +229,11 @@ def build_parts_used_pdf(db: Session, from_date: date, to_date: date, parts: lis
running_total += row["total"]
row["running_total"] = running_total
+ summary_rows = build_parts_used_type_summary(results)
+
html_content = templates.get_template("parts_used_report.html").render(
rows=results,
+ summary_rows=summary_rows,
from_date=from_date,
to_date=to_date,
)
@@ -237,6 +243,35 @@ def build_parts_used_pdf(db: Session, from_date: date, to_date: date, parts: lis
return pdf_io
+def build_parts_used_type_summary(rows: list[dict]):
+ summary_by_type = {}
+
+ for row in rows:
+ part_type = row.get("part_type") or "Other"
+ if part_type not in summary_by_type:
+ summary_by_type[part_type] = {
+ "part_type": part_type,
+ "quantity": 0,
+ "total": 0.0,
+ "running_total": 0.0,
+ }
+
+ summary_by_type[part_type]["quantity"] += row["quantity"]
+ summary_by_type[part_type]["total"] += row["total"]
+ summary_by_type[part_type]["running_total"] += row.get("running_total", 0)
+
+ summary_rows = list(summary_by_type.values())
+ summary_rows.append(
+ {
+ "part_type": "Grand total",
+ "quantity": sum(row["quantity"] for row in summary_rows),
+ "total": sum(row["total"] for row in summary_rows),
+ "running_total": sum(row["running_total"] for row in summary_rows),
+ }
+ )
+ return summary_rows
+
+
def get_part(db: Session, part_id: int):
used_subq, added_subq, current_count = _part_count_subqueries()
row = db.execute(
diff --git a/api/services/work_orders.py b/api/services/work_orders.py
index ee8cae64..aa192a9d 100644
--- a/api/services/work_orders.py
+++ b/api/services/work_orders.py
@@ -3,7 +3,7 @@
from fastapi import HTTPException
from sqlalchemy import or_, select
from sqlalchemy.exc import IntegrityError
-from sqlalchemy.orm import Session, joinedload
+from sqlalchemy.orm import Session, joinedload, undefer
from api.models.meter import Meters, MeterActivities
from api.models.user import Notifications, NotificationTypeLU, Users
@@ -146,7 +146,7 @@ def list_work_orders(
def create_work_order(
- db: Session, new_work_order: meter.CreateWorkOrder
+ db: Session, user: Users, new_work_order: meter.CreateWorkOrder
) -> meter.WorkOrder:
open_status = db.scalars(
select(workOrderStatusLU).where(workOrderStatusLU.name == "Open")
@@ -178,6 +178,7 @@ def create_work_order(
db=db,
work_order=work_order,
action="created",
+ created_by_user_id=user.id,
)
db.commit()
@@ -217,6 +218,15 @@ def update_work_order(
_work_order_query().where(workOrders.id == patch_work_order_form.work_order_id)
).first()
+ old_values = {
+ "title": work_order.title,
+ "description": work_order.description,
+ "status_id": work_order.status_id,
+ "notes": work_order.notes,
+ "creator": work_order.creator,
+ "assigned_user_id": work_order.assigned_user_id,
+ }
+
if user.user_role.name == "Technician" and work_order.assigned_user_id != user.id:
raise HTTPException(
status_code=403,
@@ -252,6 +262,7 @@ def update_work_order(
work_order=work_order,
action="updated",
created_by_user_id=user.id,
+ old_values=old_values,
)
db.commit()
@@ -274,7 +285,7 @@ def update_work_order(
)
-def delete_work_order(db: Session, work_order_id: int):
+def delete_work_order(db: Session, user: Users, work_order_id: int):
work_order = db.scalars(
select(workOrders).where(workOrders.id == work_order_id)
).first()
@@ -290,6 +301,7 @@ def delete_work_order(db: Session, work_order_id: int):
db=db,
work_order=work_order,
action="deleted",
+ created_by_user_id=user.id,
)
db.commit()
@@ -299,11 +311,82 @@ def delete_work_order(db: Session, work_order_id: int):
return {"status": "success"}
+def _format_user_display(user: Users | None) -> str:
+ if not user:
+ return "Unassigned"
+
+ name = user.display_name or user.full_name or user.email or str(user.id)
+ email = user.email
+
+ return f"{name} ({email})" if email and email != name else name
+
+
+def _get_user_display_by_id(db: Session, user_id: int | None) -> str:
+ if not user_id:
+ return "Unassigned"
+
+ user = db.scalars(
+ select(Users).options(undefer(Users.email)).where(Users.id == user_id)
+ ).first()
+
+ return _format_user_display(user)
+
+
+def _build_work_order_change_messages(
+ db: Session,
+ old_values: dict,
+ work_order: workOrders,
+) -> list[str]:
+ changes = []
+
+ field_labels = {
+ "title": "Title",
+ "description": "Description",
+ "notes": "Notes",
+ "creator": "Creator",
+ }
+
+ for field, label in field_labels.items():
+ old_value = old_values.get(field)
+ new_value = getattr(work_order, field)
+
+ if old_value != new_value:
+ changes.append(
+ f"{label} changed from {old_value or 'blank'} to {new_value or 'blank'}"
+ )
+
+ if old_values.get("status_id") != work_order.status_id:
+ old_status = db.scalars(
+ select(workOrderStatusLU.name).where(
+ workOrderStatusLU.id == old_values.get("status_id")
+ )
+ ).first()
+
+ new_status = db.scalars(
+ select(workOrderStatusLU.name).where(
+ workOrderStatusLU.id == work_order.status_id
+ )
+ ).first()
+
+ changes.append(
+ f"Status changed from {old_status or 'blank'} to {new_status or 'blank'}"
+ )
+
+ if old_values.get("assigned_user_id") != work_order.assigned_user_id:
+ old_user = _get_user_display_by_id(db, old_values.get("assigned_user_id"))
+ new_user = _get_user_display_by_id(db, work_order.assigned_user_id)
+
+ changes.append(f"Assigned user changed from {old_user} to {new_user}")
+
+ return changes
+
+
def _create_work_order_notifications(
db: Session,
work_order: workOrders,
- action: str, # "created" or "updated" or "deleted"
+ action: str,
created_by_user_id: int | None = None,
+ old_values: dict | None = None,
):
notification_type = db.scalars(
select(NotificationTypeLU).where(NotificationTypeLU.name == "work_order")
@@ -329,8 +412,26 @@ def _create_work_order_notifications(
if not recipient_user_ids:
return
+ changed_by = _get_user_display_by_id(db, created_by_user_id)
+
title = f"Work order {action}: {work_order.title}"
- message = f"Work order #{work_order.id} has been {action}."
+
+ if action == "updated" and old_values:
+ change_messages = _build_work_order_change_messages(
+ db=db,
+ old_values=old_values,
+ work_order=work_order,
+ )
+
+ if change_messages:
+ message = (
+ f"{changed_by} updated Work order #{work_order.id}. "
+ f"Changes: {'; '.join(change_messages)}."
+ )
+ else:
+ message = f"{changed_by} updated Work order #{work_order.id}."
+ else:
+ message = f"{changed_by} {action} Work order #{work_order.id}."
notifications = [
Notifications(
diff --git a/api/templates/installed_meters_report.html b/api/templates/installed_meters_report.html
new file mode 100644
index 00000000..8d6ecca3
--- /dev/null
+++ b/api/templates/installed_meters_report.html
@@ -0,0 +1,97 @@
+
+
+
+
+ Installed Meters Report
+
+
+
+ Installed Meters Report
+
+ {{ from_date.strftime("%Y-%m-%d") }} to {{ to_date.strftime("%Y-%m-%d") }}
+ {% if min_size is not none or max_size is not none %}
+ | Meter size:
+ {{ min_size if min_size is not none else "Any" }}
+ to
+ {{ max_size if max_size is not none else "Any" }}
+ {% endif %}
+
+
+
+
Total meters installed{{ summary.quantity }}
+
Total value${{ "%.2f"|format(summary.total_value) }}
+
+
+ Meter Type Totals
+ {% if meter_type_chart %}
+
+

+
+ {% endif %}
+
+
+
+
+ | Meter Type |
+ Size |
+ Quantity Installed |
+ Total Value |
+
+
+
+ {% for row in type_totals %}
+
+ | {{ row.meter_type }} |
+ {{ "%g"|format(row.size) }} |
+ {{ row.quantity }} |
+ ${{ "%.2f"|format(row.total_value) }} |
+
+ {% endfor %}
+
+
+
+ Installed Meters
+
+
+
+ | Installed Date |
+ Serial Number |
+ Meter Type |
+ Size |
+ RA Number |
+ TRSS |
+ Water Users |
+ Value |
+
+
+
+ {% for row in rows %}
+
+ | {{ row.installed_date.strftime("%Y-%m-%d") }} |
+ {{ row.serial_number }} |
+ {{ row.meter_type }} |
+ {{ "%g"|format(row.size) }} |
+ {{ row.well_ra_number or "" }} |
+ {{ row.trss or "" }} |
+ {{ row.water_users or "" }} |
+ ${{ "%.2f"|format(row.price) }} |
+
+ {% endfor %}
+
+
+
+
diff --git a/api/templates/parts_used_report.html b/api/templates/parts_used_report.html
index dab25a57..2b9d653f 100644
--- a/api/templates/parts_used_report.html
+++ b/api/templates/parts_used_report.html
@@ -33,6 +33,24 @@ Parts Usage Report
To:
{{ to_date }}
+
+
+
+ | Type |
+ Number of units |
+ Total cost |
+
+
+
+ {% for row in summary_rows %}
+
+ | {{ row.part_type }} |
+ {{ row.quantity }} |
+ ${{ "%.2f"|format(row.total) }} |
+
+ {% endfor %}
+
+
@@ -59,4 +77,4 @@ Parts Usage Report